Skip to content

Events (Vue) — API Reference ​

ts
import {
  attachEvents, createEventClient, DEFAULT_CHECK_INTERVAL_MS, defineEvent, eventsOf, handleEventStream,
  applyChanges, RECORDS_CHANGED,
  DEFAULT_POLL_SETTINGS, jitterMultiplier, ladderInterval, nextDelay, PollLadder,
} from '@dynamicforms/fastapi-viewsets';

The frontend side of the events API: an event client that follows topics over a muxws peer, over axios, or both, the polling ladder it schedules its polls with, and the records_changed payload that watch() delivers. The guide is Events (Vue); the backend side is fastapi_viewsets.events.

createEventClient ​

ts
function createEventClient(options: EventClientOptions): EventClient;

interface EventClientOptions {
  /** The path the BE's `register_events` serves, e.g. `/events`. */
  basePath: string;
  /** Carries pushes and, while open, the client's calls. */
  peer?: EventPeerLike;
  /** Carries the client's polls while there is no open peer. */
  axios?: EventAxiosLike;
  /** The ladder's constants. */
  pollSettings?: PollSettings;
  /** The ladder's jitter draw. */
  draw?: Draw;
  /** Milliseconds between the check polls over the open peer; `0` sends none. */
  checkIntervalMs?: number;
}

const DEFAULT_CHECK_INTERVAL_MS = 60000;
OptionTypeDefaultDescription
basePathstring—The path of the BE's events viewset. One trailing slash is stripped.
peerEventPeerLike—A muxws Peer. Carries pushes, and the client's calls while it is open. The client registers one onClose and one onReconnect handler on it.
axiosEventAxiosLike—An AxiosInstance, or the axios module. Carries poll while there is no open peer.
pollSettingsPollSettingsDEFAULT_POLL_SETTINGSThe constants of the polling ladder.
drawDrawMath.randomThe jitter draw of the polling ladder.
checkIntervalMsnumberDEFAULT_CHECK_INTERVAL_MS (60000)The interval of the check poll, jittered by pollSettings.jitterFraction and drawn with draw. 0, a negative and a non-finite value send none.

The client is registered for its peer until close(); eventsOf(peer) returns it.

Throws ErrorWhen
createEventClient needs a peer, axios, or bothNeither peer nor axios is given
This peer already has an event clientpeer has a registered client that is not closed

eventsOf ​

ts
function eventsOf(peer: object): EventClient | undefined;

The client registered for peer, or undefined. A muxws ViewSet's watch() uses it when the ViewSet's options carry no events.

EventClient ​

ts
interface EventClient {
  readonly basePath: string;
  subscribe<P>(topic: string, event: EventDefinition<P>, handlers: EventHandlers<NoInfer<P>>): EventSubscription;
  close(): void;
}
MemberDescription
basePathEventClientOptions.basePath without its trailing slash.
subscribe(topic, event, handlers)Subscribes handlers to the events named event.name on topic. The client holds one server subscription and one rev per topic, and counts the subscriptions on it: the first subscription of a topic sends subscribe over an open peer, or adds the topic to the next poll; a further one sends nothing, and its ready resolves at once when the topic already holds a rev. P is read off event only. Throws Error('The event client is closed') after close().
close()Clears the client's timer, sends unsubscribe for every topic subscribed on an open peer, and for every topic whose subscribe is in flight once it is answered on the same socket, rejects every ready that has not settled with Error('The event client is closed'), ignores every later answer and push, and unregisters the client from its peer, which may then be given a new client. It calls no handler. A second call does nothing.

defineEvent ​

ts
function defineEvent<P>(name: string): EventDefinition<P>;

interface EventDefinition<P> {
  /** The BE event's `event_name`. */
  readonly name: string;
}

Returns a frozen { name }. P is the payload type onEvent receives; it exists in the type only. The client dispatches on the pair (topic, name), whatever the event's version, so P describes the payloads of every version a subscription handles: one type whose fields of other versions are optional, or a union of one type per version.

EventHandlers ​

ts
interface EventHandlers<P> {
  onEvent: (payload: P, meta: EventMeta) => void;
  onResync: () => void;
  onError?: (error: unknown) => void;
}
HandlerCalled
onEventRequired. With the payload and the EventMeta of every applied event of the topic whose name is the subscription's, whatever its version, in rev order.
onResyncRequired. When the client cannot tell which events of the topic it missed; see resync. Every subscription of the topic is called, whatever its event name.
onErrorWith the error of a refusal of the topic; the subscription is removed. Becomes the initial EventSubscription.onError.

The handlers are function-typed properties, so their parameters are checked strictly: an onEvent whose payload parameter type is not P, a type with a field P lacks included, or whose meta parameter type is not EventMeta, does not compile, and a defineEvent without a type argument has an unknown payload. An onEvent that declares only the payload parameter compiles. A handler that throws does not stop the client or the other handlers. Its error is rethrown in a microtask, where it reaches the environment's uncaught-error reporting.

EventMeta ​

ts
interface EventMeta {
  readonly topic: string;
  readonly rev: number;
  readonly prev: number;
  readonly version: number;
}
FieldValue
topicThe topic the event was published to.
revThe event's rev on its topic.
prevThe rev the client held for the topic before it applied the event: the push's :prev, or the poll answer entry's prev, which chains the events of the answer.
versionThe backend event's event_version, an integer of at least 1: the push's :version header, or the poll answer entry's version.

One frozen object per applied event, passed to every subscription the event is dispatched to. version does not narrow the type of the payload.

EventSubscription ​

ts
interface EventSubscription {
  readonly ready: Promise<void>;
  unsubscribe(): void;
  onError?: (error: unknown) => void;
}
MemberDescription
readyResolves once the topic holds a rev: the subscribe over the peer is answered, or a poll answered the topic. Rejects with the error of a refusal that comes first, with the subscription removed, and with Error('The event client is closed') when close() comes first. A rejection nobody awaits is not reported as unhandled.
unsubscribe()Removes the subscription. The last subscription of a topic ends the client's following of it: over an open peer with an unsubscribe call, sent once a subscribe still in flight is answered; otherwise the topic is left out of the next poll. A second call does nothing.
onErrorCalled with the error of the refusal that removes the subscription. Writable; initially EventHandlers.onError.

A refusal is reported with:

RefusalError
subscribe or poll answered over the peer with a refusing statusViewSetRequestError(status, body, headers)
poll answered over axios with a refusing statusthe AxiosError axios throws
a topic of a poll answer carries errorViewSetRequestError with response.data { detail, detail_code: error, detail_params: { topic } } and the status below
Topic errorresponse.status
event_topic_invalid422
event_topic_refused403
not_authorized403
session_expired401
rate_limited429, not a refusal: the topic stays and is polled on the next rung
any other code403

A poll refused with a status refuses every topic it carried.

Calls ​

The client sends three calls, each as one POST to {basePath}/{name}:

CallBodyAnswerSent
subscribe{ topic }{ rev }Over an open peer only: for the first subscription of a topic, and for every followed topic on onReconnect
unsubscribe{ topic }—Over an open peer only, for a topic subscribed on its socket: on the last subscription's unsubscribe(), on a refusal of the topic, and on close(), also once a subscribe in flight at close() is answered. Its failure is not reported
poll{ cursors: { [topic]: rev | null } }{ topics: { [topic]: { rev, events, error? } }, poll_after_ms }, each event { rev, prev, event, version, payload }Over an open peer, otherwise over axios

Over the peer a call is one stream, peer.open(body, { headers: { ':method': 'POST', ':path': path }, end: true }), and its status is the reply's :status header, 200 when absent. Over axios it is axios.post(path, body). No call carries Idempotency-Key, and no call is repeated by the repeat machinery of the proxies.

The subscribe and unsubscribe calls of one topic on one peer are sent one at a time, whichever client of the peer sends them: a call is opened once the one before it is answered. The backend serves the streams of a socket concurrently, and an unsubscribe answered after a later subscribe of the topic would end that subscription. A new client of a peer therefore subscribes a topic once the closed client's unsubscribe of it is answered.

A failure with a status from 400 to 499 refuses, except 429. 429, every status of 500 or above, and a failure without a status (ConnectionLost, a network error) are not reported: a failed rung poll is sent again on the next rung, a failed gap poll leaves the topic to the ladder (see below), and a failed subscribe is sent again on the next reconnect, with the topic polled meanwhile.

Push, gap, poll and resync ​

Per topic the client holds rev, the rev of the last event it applied or of the first answer, and null before that answer.

ArrivalConditionAction
push or polled eventprev equals the held revonEvent on every subscription of the topic with the event's name, whatever its version; the held rev becomes the event's rev
push or polled eventrev at most the held revignored
pushrev above the held rev, any other prevone poll of that topic from the held rev
pushthe topic holds no rev yetheld, and replayed by these rules once the first answer sets the rev
subscribe or poll answerthe topic holds no rev yetthe held rev becomes the answer's rev; ready resolves
poll answerevents is an arrayeach event as above; an event whose prev is below the held rev and whose rev is above it is applied as one whose prev equals the held rev. After the events, the held rev becomes the answer's rev when that is larger: the answer leaves out the events the BE's delivery filters refuse, and its rev covers them
poll answerevents: nullonResync on every subscription of the topic; the held rev becomes the answer's rev
poll answerrev below the cursor the poll carriedonResync; the held rev becomes the answer's rev
poll answeran event in events that is malformed (without a rev or prev that is a number or a string of one, a string event, or a version that is an integer of at least 1 or a string of one), or is neither applied nor ignoredonResync; the held rev becomes the answer's rev
subscribe answerrev at most the held revnothing; the topic resumes without a read
subscribe answerrev above the held revone poll of that topic from the held rev
gap pollfails without refusing the topic, or its answer does not answer the topicthe ladder runs, also over an open peer, and polls the topic until a poll answers it
anya topic the client does not followignored

A topic answer without a numeric rev and without error is ignored. events: null is not a resync when pushes applied while the poll was in flight moved the held rev to at least the answer's rev. A push whose headers lack :topic, :event, a :rev or :prev that is a number or a string of one, or a :version that is an integer of at least 1 or a string of one, is ignored.

A gap poll is sent at once, over the open peer or over axios, and not when there is neither. A gap that arrives while a gap poll of the topic is in flight is polled again after it, when the held rev has not reached that gap's rev by then. A gap poll is not sent for a topic the client no longer follows, also when a handler of a held push replayed before it ended the following.

The peer ​

Peer stateThe client
open, every followed topic subscribed on its socketholds the ladder; sends a check poll once per checkIntervalMs
open, a topic not subscribed on its socket (a subscribe in flight, or one that failed without refusing), or a topic whose gap poll or check poll failed without refusing or left the topic unansweredruns the ladder over the peer
onClose fireddrops every socket subscription and resumes the ladder on its base rung over axios; without axios it waits for onReconnect
onReconnect firedsends subscribe for every followed topic, runs the ladder over the peer until every one is answered, then holds it

A check poll is one poll over the open peer of every topic that is subscribed on its socket, holds a rev, and has no gap poll in flight, sent while the ladder is held. It closes a gap the next push would reveal, when no next push comes: a push the BE lost on its way to the socket. Its answer is applied as a rung's. A topic the answer does not answer, and every topic of a check that fails without refusing them, start the ladder until a poll answers them; a refusal refuses the topics. The check timer runs while the client has a peer and follows a topic; a tick on which the peer is not open or the ladder runs sends nothing.

A subscribe refused on the new socket refuses the topic. An answer from a socket that has since closed is dropped. The peer counts as open while peer.isOpen is not false; a peer without isOpen counts as open until onClose and again from onReconnect.

attachEvents and handleEventStream ​

ts
function attachEvents(peer: EventPeerLike, fallback?: EventStreamHandler): void;
function handleEventStream(peer: object, payload: unknown, stream: EventStreamLike): boolean;

attachEvents installs peer.onStream: a stream whose headers carry :event goes to the peer's client, any other stream to fallback, or is reset with REFUSED (4) and the reason no handler for this stream without one. A muxws peer has one handler slot, and attachEvents takes it. Throws Error('attachEvents needs a peer with onStream') for a peer without onStream.

handleEventStream is for a handler that owns the slot: it returns true for a stream whose headers carry :event as a string, having passed it to peer's client, and false, having done nothing, for any other. An event stream on a peer without a client returns true and is dropped.

ts
peer.onStream(async (payload, stream) => {
  if (handleEventStream(peer, payload, stream)) return;
  await ownHandler(payload, stream);
});

Peer and axios types ​

ts
/** A muxws peer as the event client uses it. `onStream` is needed by `attachEvents` only. */
interface EventPeerLike extends MuxwsPeerLike {
  onStream?(handler: EventStreamHandler): void;
}

/** A stream the remote opened. */
interface EventStreamLike {
  readonly headers: Record<string, unknown>;
  reset?(code: number, reason?: string): Promise<void>;
}

type EventStreamHandler = (payload: any, stream: EventStreamLike) => void | Promise<void>;

/** The axios method the client uses; an `AxiosInstance` is one. */
interface EventAxiosLike {
  post<R = unknown>(url: string, data?: unknown): Promise<{ data: R }>;
}

MuxwsPeerLike is in route_muxws. A muxws Peer satisfies EventPeerLike, a muxws Stream satisfies EventStreamLike.

records_changed ​

ts
/** The payload of the BE's `records_changed` event: one action's changes. */
interface RecordsChanged<T, K extends KeyType = KeyType> {
  op: 'created' | 'updated' | 'deleted';
  changes: Change<T, K>[];
}

/** One changed record. */
interface Change<T, K extends KeyType = KeyType> {
  pk: K;
  /** The record retrieve(pk) answered; set by watch({ fetch: true }), absent otherwise. */
  record?: T | null;
}

const RECORDS_CHANGED: EventDefinition<RecordsChanged<unknown>>;

function applyChanges<T, K extends KeyType = KeyType>(
  rows: readonly T[],
  change: RecordsChanged<T, K>,
  pkFieldName: string,
): T[];

RECORDS_CHANGED is defineEvent('records_changed'), the event watch() subscribes a ViewSet's basePath to. The backend's RecordsChanged is version 1, and RecordsChanged<T, K> is the payload of that version. watch() calls onChange for version 1 only and onResync for an event of any other version.

applyChanges(rows, change, pkFieldName) returns a new array and leaves rows unchanged:

  • deleted removes every row whose row[pkFieldName] is a changed pk;
  • created and updated replace every row with the change's pk by the change's record, or append the record when no row has that pk; a change without record is skipped.

Pks compare by their string form, so 7 and '7' name one row. Applying one change twice gives the same rows as applying it once.

Polling ladder ​

The cadence of the client's polls, as pure functions: nothing here touches a timer, a clock or a transport. The event client calls PollLadder; the functions are exported for a scheduler of one's own.

PollSettings ​

ts
interface PollSettings {
  /** The interval while anything is active, and after any rev change. */
  baseIntervalMs: number;
  /** The multiplier applied when no rev changed. */
  backoffFactor: number;
  /** The upper bound of the climb, and the cadence while a dialog is open and while idle. */
  ceilingMs: number;
  /** The jitter, as a fraction either way. */
  jitterFraction: number;
}

const DEFAULT_POLL_SETTINGS: PollSettings = {
  baseIntervalMs: 400,
  backoffFactor: 1.5,
  ceilingMs: 5000,
  jitterFraction: 0.2,
};

PollLadderState ​

ts
interface PollLadderState {
  /** Anything polled is active and expected to change; false polls at the ceiling. */
  anyActive: boolean;
  /** A dialog is open on anything polled; polls at the ceiling. */
  anyDialogOpen: boolean;
  /** Any polled rev moved since the previous read. */
  revChanged: boolean;
  /** The previous un-jittered ladder interval, or null on the first poll. */
  previousDelay: number | null;
  /** The server's poll_after_ms; only ever raises the delay. */
  pollAfterMs?: number | null;
}

A caller that cannot tell whether anything is active passes anyActive: true. The event client does, with anyDialogOpen: false.

Draw ​

ts
/** A uniform draw in [0, 1). */
type Draw = () => number;

ladderInterval ​

ts
function ladderInterval(state: PollLadderState, settings?: PollSettings): number;

The interval in milliseconds, without jitter and without pollAfterMs. The first matching branch answers:

  1. anyDialogOpen: ceilingMs;
  2. !anyActive: ceilingMs;
  3. revChanged: baseIntervalMs;
  4. previousDelay is null or undefined: baseIntervalMs;
  5. otherwise min(max(previousDelay, baseIntervalMs) * backoffFactor, ceilingMs).

No branch stops polling.

jitterMultiplier ​

ts
function jitterMultiplier(draw?: Draw, settings?: PollSettings): number;

1 + (draw() * 2 - 1) * jitterFraction, in [1 - jitterFraction, 1 + jitterFraction). draw defaults to Math.random.

nextDelay ​

ts
function nextDelay(state: PollLadderState, draw?: Draw, settings?: PollSettings): number;

Milliseconds until the next poll: max(round(max(ladderInterval(state), advised) * jitterMultiplier(draw)), max(advised, baseIntervalMs)), where advised is pollAfterMs when it is a finite number and 0 otherwise. The result is never below pollAfterMs and never below baseIntervalMs.

PollLadder ​

ts
class PollLadder {
  constructor(settings?: PollSettings, draw?: Draw);
  get ladder(): number | null;
  next(state: Omit<PollLadderState, 'previousDelay'>): number;
  reset(): void;
  resume(): number;
}
MemberDescription
constructor(settings, draw)Defaults: DEFAULT_POLL_SETTINGS, Math.random.
ladderThe un-jittered interval the last next() or resume() set, null before the first one.
next(state)nextDelay of state with previousDelay set to ladder, and remembers the new ladderInterval as ladder.
reset()Sets ladder to null; the next next() answers the base rung.
resume()Sets ladder to baseIntervalMs and returns it jittered, floored at baseIntervalMs. It does not consult ladderInterval, so an open dialog or an idle state does not delay the first poll after a lost push transport.

With DEFAULT_POLL_SETTINGS and no rev change, successive next() calls climb through these intervals; the delay is the interval jittered by ±20 %, floored at 400 ms and at pollAfterMs:

RungInterval (ms)Delay (ms)
1400400–480
2600480–720
3900720–1080
413501080–1620
520251620–2430
63037.52430–3645
74556.253645–5468
8 and later50004000–6000

A rev change returns the next interval to 400 ms.

Released under the MIT License.