Documentation menu

Network streams

Optional @gyral/http/sse and @gyral/http/websocket entries use browser-native transports. They are separate from the fetch entry, add no runtime dependencies, and open no resources on import or factory creation. Use normal commands, declarative subscriptions and fake substitution by driver name. Feeds start after the first render, preserving SSR seeds.

Server-sent events

// src/server-count.ts
import { command, define, html } from '@gyral/core';
import {
  makeEventSourceDriver,
  type EventSourceError,
  type EventSourceOutput,
} from '@gyral/http/sse';
import * as v from 'valibot';

const schema = v.object({ count: v.number() });
const feed = makeEventSourceDriver(schema, { events: ['count'], withCredentials: false });
type Msg =
  | { readonly _tag: 'Feed'; readonly value: EventSourceOutput<v.InferOutput<typeof schema>> }
  | { readonly _tag: 'Failed'; readonly error: EventSourceError };
interface State {
  readonly count: number;
  readonly status: string;
}
export const Counter = define<State, Msg>()('my-server-count', {
  init: () => ({ count: 0, status: 'connecting' }),
  subscriptions: () => [
    command(
      feed,
      { url: '/events' },
      {
        onSuccess: (value) => ({ _tag: 'Feed', value }),
        onFailure: (error) => ({ _tag: 'Failed', error }),
      },
    ),
  ],
  update: {
    Feed: (s, m) => {
      switch (m.value._tag) {
        case 'EventSourceMessage':
          return { ...s, count: m.value.data.count };
        case 'EventSourceState':
          return { ...s, status: m.value.state };
        case 'EventSourceDecodeFailed':
          return { ...s, status: 'invalid event' };
      }
    },
    Failed: (s) => ({ ...s, status: 'closed' }),
  },
  view: (s) =>
    html`<output>${s.count}</output>
      <p role="status">${s.status}</p>`,
});

The driver always observes message and adds configured named events (no wildcard native listener). EventSourceMessage preserves eventType and lastEventId; empty IDs are valid. Default parsing is JSON followed by Standard Schema validation. Use parse(text) for another text format. Async decoders preserve arrival order. Bad JSON/schema data emits EventSourceDecodeFailed { issues, eventType, lastEventId } without ending the connection.

Native EventSource owns GET requests, cookie credentials, reconnection, server retry: timing and Last-Event-ID. withCredentials configures cross-origin credentials; same-origin cookies follow native behavior. CONNECTING errors report connecting state and keep the same source. Terminal CLOSED errors report closed state and reject with EventSourceError through onFailure; detailed HTTP status/body are unavailable. The driver adds no second retry loop. Abort removes listeners, closes the source and suppresses pending decoded results.

Headers, POST streams and fetch-based transports are outside this driver. Event IDs do not guarantee exactly-once delivery or replay; handle deduplication/resynchronization in your protocol. Tests use fakeDriver(feed) with el.drivers = { sse: fake } (or the configured name) and fake.emitNext(...) to deliver events/states.

WebSocket

// src/native-chat.ts
import { command, define, html, retry } from '@gyral/core';
import {
  makeWebSocketDriver,
  type WebSocketError,
  type WebSocketOutput,
} from '@gyral/http/websocket';
import * as v from 'valibot';

const schema = v.object({ text: v.string() });
const raw = makeWebSocketDriver(schema, {
  encode: (value: { text: string }) => JSON.stringify(value),
});
const socket = retry(raw, {
  times: 3,
  delayMs: 500,
  backoff: 'exponential',
  jitter: true,
  retryIf: (error) => error._tag === 'WebSocketClosedError' && error.code === 1006,
});
interface State {
  readonly live: boolean;
  readonly text: string;
  readonly status: string;
}
type Msg =
  | { readonly _tag: 'Socket'; readonly value: WebSocketOutput<v.InferOutput<typeof schema>> }
  | { readonly _tag: 'Failed'; readonly error: WebSocketError }
  | { readonly _tag: 'Send'; readonly text: string }
  | { readonly _tag: 'Stop' };
const handlers = {
  onSuccess: (value: WebSocketOutput<v.InferOutput<typeof schema>>): Msg => ({
    _tag: 'Socket',
    value,
  }),
  onFailure: (error: WebSocketError): Msg => ({ _tag: 'Failed', error }),
};
export const Chat = define<State, Msg>()('my-native-chat', {
  init: () => ({ live: true, text: '', status: 'connecting' }),
  subscriptions: (s) =>
    s.live
      ? [
          command(
            socket,
            {
              _tag: 'Connect',
              connection: 'room',
              url: 'wss://example.test/chat',
            },
            { ...handlers, key: 'room-connect' },
          ),
        ]
      : [],
  intent: { Send: ({ value }) => ({ _tag: 'Send', text: value ?? '' }), Stop: true },
  update: {
    Send: (s, m) => [
      s,
      [
        command(
          socket,
          {
            _tag: 'Send',
            connection: 'room',
            value: { text: m.text },
          },
          { ...handlers, key: 'room-send' },
        ),
      ],
    ],
    Stop: (s) => [
      s,
      [
        command(
          socket,
          {
            _tag: 'Close',
            connection: 'room',
            code: 1000,
          },
          { ...handlers, key: 'room-close' },
        ),
      ],
    ],
    Failed: (s, m) => ({ ...s, live: false, status: m.error._tag }),
    Socket: (s, m) => {
      switch (m.value._tag) {
        case 'WebSocketMessage':
          return { ...s, text: m.value.data.text };
        case 'WebSocketState':
          return { ...s, status: m.value.state };
        case 'WebSocketDecodeFailed':
          return { ...s, status: 'invalid message' };
        case 'WebSocketClosed':
          return m.value.requested || (m.value.code === 1000 && m.value.wasClean)
            ? { ...s, live: false, status: 'closed' }
            : { ...s, status: 'reconnecting' };
        case 'WebSocketSent':
          return s; // enqueued, awaiting application acknowledgement
        case 'WebSocketCloseRequested':
          return s;
      }
    },
  },
  view: (s, i) =>
    html`<p>${s.text}</p>
      <p role="status">${s.status}</p>
      <button type="button" value="Hello" data-intent=${i.Send}>Send hello</button>
      <button type="button" data-intent=${i.Stop}>Close connection</button>`,
});

Each factory owns sockets by explicit connection ID. Only one active Connect owns an ID; duplicates reject. Send/Close use the same socket; another factory has separate ownership. Choose unique IDs when providing one driver to multiple components and distinct command keys for Connect, Send and Close. A shared connection should have one subscribing owner; fan its messages out through application state instead of issuing duplicate Connect commands.

Default incoming parse is JSON text followed by Standard Schema; binary messages are ArrayBuffers (binaryType: 'arraybuffer'). Set synchronous parse(payload) for binary/other text formats. Async schemas preserve message order. Malformed payloads emit WebSocketDecodeFailed without ending the connection. Required encode(value) returns a native string, Blob, ArrayBuffer or ArrayBufferView. Server-rendered model state stays JSON; binary protocol values belong at the driver boundary or in client-only state.

The outgoing limit defaults to 65,536 bytes. maxBufferedBytes must be a nonnegative safe integer (invalid configuration throws before resources open). Send requires an owned OPEN socket and native bufferedAmount plus encoded bytes within the limit (UTF-8 for strings). Failure uses WebSocketSendError with reason not-open/buffer-limit/encode/send. A successful WebSocketSent means browser enqueue, not server processing or acknowledgement. No offline queue or automatic resend exists. Native WebSocket has no inbound backpressure; the outgoing limit does not bound incoming load.

Clean normal code 1000 closure resolves Connect with WebSocketClosed; an explicit Close (default code 1000) also completes, including application close codes. requested: true identifies that explicit request. Other closures emit WebSocketClosed, then reject with WebSocketClosedError retaining code/reason/wasClean and errored (native error occurred). Constructor/duplicate-ID errors use WebSocketConnectError; invalid Close requests use WebSocketCloseError. Native errors expose no HTTP details.

Reconnection is opt-in through retry. Choose backoff/jitter and a retryIf predicate that accepts your recoverable connection failures and excludes Send errors. Do not drop the Connect subscription merely because an abnormal attempt emitted Closed: that aborts the retry wrapper too. Drop it on requested/normal completion or final onFailure. An ended subscription stays ended until its lane/input is removed and re-added/changed. Abort never retries; cleanup removes listeners, releases ownership, closes the socket and suppresses late events/decoders. Application acknowledgements and resynchronization are protocol data. Tests use fakeDriver(raw) by name (websocket by default) for both streams and sends.