Primitives / Network

websocket

A duplex socket as a bundle. The factory takes a name (the resource tag is per module, so one assembly holds one socket) and an optional createSocket for tests; the placement takes { url, connectTimeoutMs? }.

ts
import { websocket, type SocketService } from 'foldkit-primitives/net'

const ChatSocket = websocket({ name: 'ChatSocket' })

const Chat = Bundle.compose({ wantConnection: Schema.Boolean }).pipe(
  Bundle.withServices<SocketService>(),
  Bundle.withChild('socket', ChatSocket, {
    args: { url: 'wss://example.com/chat', connectTimeoutMs: 5000 },
    when: model => model.wantConnection,
  }),
)

status moves closed → connecting → open. The resource owns the socket and acquires it while the placement is active (when above). Sending is a placed helper, Chat.children.socket.helpers.send(data), a step to return from update: it writes through the resource and yields Sent { data } on dispatch (not delivery), or SendFailed when no socket is open, since a closed socket's send is a silent no-op per spec. The socket service rides the assembly into the application's resources, which is what withServices names.

Received { data } notifies without storing; project the payload into your own field to keep it. With connectTimeoutMs, a socket still connecting when the time runs out is closed and reported TimedOut; without one it waits as long as the browser does.

The Model words its own errors for a reader (Failed to connect to WebSocket, Connection error, Connection timeout, Socket unavailable), chosen by whether the socket had opened. A view reads SocketView through viewOf(model, wanted), one of Disconnected, Connecting, Connected, or Error { error }, instead of keeping a connection state machine of its own; isOpen(model) gates sends. examples/foldkit/websocket-chat is a whole page over this bundle.

sse

The one-directional sibling: the server speaks, the Model listens. Same shape minus send. A Failed records the error but stays connecting, because the browser reconnects a dropped stream itself. Payloads are always text, per the SSE spec. isLive(model) and viewOfSse(model, wanted) mirror the socket's readers.

Broadcast

broadcastMessages(name) is an entry: posts from other tabs on the named channel arrive as Received { data }, and the parent maps them into its own Message. There is no bundle because a channel owns no state. postBroadcast(name, data) is the one-shot Command, yielding Posted or BroadcastFailed. A post never echoes to its own channel, per spec.