glyde/gateway

The Discord gateway protocol as a pure state machine.

No sockets, no timers, no clock, no randomness, no logging, no processes. step is a total function of (Shard, Input) returning the Outputs the caller must perform, whose results come back as the next Inputs.

The adapter contract:

  1. Perform every output of a batch, in the order given, before feeding the next input.
  2. ArmTimer replaces any timer already armed under the same Timer; CancelTimer on an unarmed timer is a no-op; deliver Fired with the exact Stamp you were given.
  3. Store the Conn from Open and hand that value back on every socket input. Never synthesise one.
  4. Commit the new Shard before performing the batch, and queue any input produced during performance rather than re-entering step.
  5. A send that fails is reported as Closed(conn, None). Never throw out of an effect fold.
  6. Seed the shard from something below 2^31 and different per shard.

Types

Heartbeat bookkeeping. Exists from HELLO onwards, so it lives in the phase.

pub type Beat {
  Beat(interval_ms: Int, unacked: Int, quiet: Bool)
}

Constructors

  • Beat(interval_ms: Int, unacked: Int, quiet: Bool)

    Arguments

    interval_ms

    From HELLO. Inside [1000, 600_000], because the only thing that reaches this field is a frame.HeartbeatInterval, which cannot hold anything else.

    unacked

    Heartbeats sent since the last op 11. Reset by op 11 only: counting dispatch traffic stops zombie detection on the busiest connections.

    quiet

    True when no frame of any kind has arrived since the last heartbeat. Reported in Notice.Zombie and never consulted by the verdict.

The outbound command window: Discord allows 120 events per 60 seconds per connection and answers a flood with close 4008. Fresh on every reconnect.

pub type Budget {
  Budget(spent: Int, capacity: Int)
}

Constructors

  • Budget(spent: Int, capacity: Int)
pub type Compression {
  NoCompression
  ZlibStream
}

Constructors

  • NoCompression

    One WebSocket text frame is one gateway payload. The only mode glyde’s own adapters implement.

  • ZlibStream

    compress=zlib-stream. Binary frames, payloads terminated by 00 00 FF FF. The adapter owns the inflate context; the core frames.

Everything that does not change over a shard’s life. config/2 defaults all but the token and intents, so the long tail is a record update:

let assert Ok(sharding) = gateway.sharding(index: 3, count: 16)
Config(..gateway.config(token:, intents:), sharding:)
pub type Config {
  Config(
    token: String,
    intents: intents.Intents,
    sharding: frame.Sharding,
    properties: frame.Properties,
    host: Host,
    api_version: Int,
    compression: Compression,
    presence: option.Option(presence.Presence),
    large_threshold: frame.LargeThreshold,
    tuning: Tuning,
  )
}

Constructors

  • Config(
      token: String,
      intents: intents.Intents,
      sharding: frame.Sharding,
      properties: frame.Properties,
      host: Host,
      api_version: Int,
      compression: Compression,
      presence: option.Option(presence.Presence),
      large_threshold: frame.LargeThreshold,
      tuning: Tuning,
    )

    Arguments

    properties

    Sent in IDENTIFY. Analytics only; Discord does not act on it.

    host

    The host to dial when there is no resume host.

    api_version

    Gateway API version. A missing v routes to version 6, where intents are optional, so this is always sent.

    compression

    Transport compression. NoCompression unless the adapter you are using documents support; see Output.Inflate.

    presence

    Presence to send in IDENTIFY. None means online with no activity.

    large_threshold

    Guild size above which Discord sends an offline member list. 50 is Discord’s default, and large_threshold clamps to the legal range.

Identifies one connection attempt. Socket inputs must carry it back, so the echo of a close we asked for is dropped, not read as a fresh failure.

pub type Conn {
  Conn(Int)
}

Constructors

  • Conn(Int)

The two ways a connection’s bytes stop being trustworthy. Its own type and not a Notice, because only these two tear the connection down.

pub type Corruption {
  BufferFull(bytes: Int)
  InflateBroke(why: InflateFailure)
}

Constructors

One armed timer, as an adapter has to hold it. at is an instant on the adapter’s own clock, not the delay ArmTimer gave: the core has no clock, so only the adapter can say when “in 45 seconds” falls.

pub type Deadline {
  Deadline(timer: Timer, at: Int, stamp: Stamp)
}

Constructors

Why a WebSocket upgrade never came up. Discord answers a dead token with 401 and a bot dialling too often with 429, and the two need different answers, so the status travels rather than being flattened into prose.

pub type DialFailure {
  Refused(status: Int, detail: String)
  Unreachable(detail: String)
}

Constructors

  • Refused(status: Int, detail: String)

    The server answered the upgrade with a status other than 101.

  • Unreachable(detail: String)

    No answer to read a status from: DNS, TLS, a refused port, a socket that died mid-upgrade, or a transport that cannot see the response.

pub type Event {
  Ready(
    session_id: String,
    user: id.Id(id.User),
    resume_host: Host,
    guild_count: Int,
  )
  Resumed
  Dispatch(name: String, seq: Int, data: dynamic.Dynamic)
  Reconnecting(in_ms: Int, resuming: Bool, why: Why)
  Halted(reason: Halt)
}

Constructors

  • Ready(
      session_id: String,
      user: id.Id(id.User),
      resume_host: Host,
      guild_count: Int,
    )

    Arguments

    guild_count

    How many guilds READY listed. The guilds themselves arrive as GUILD_CREATE dispatches after it.

  • Resumed
  • Dispatch(name: String, seq: Int, data: dynamic.Dynamic)

    A dispatch, undecoded; glyde/event is the optional typed layer. READY and RESUMED arrive here too, after Ready and Resumed.

  • Reconnecting(in_ms: Int, resuming: Bool, why: Why)

    The connection is gone and a reconnect is armed.

  • Halted(reason: Halt)

    Terminal.

pub type Halt {
  Fatal(reason: close.Reason, shard: frame.Sharding)
  Requested
  IdentifyTooLarge(bytes: Int)
  NoProgress(attempts: Int)
}

Constructors

  • Fatal(reason: close.Reason, shard: frame.Sharding)

    Discord refused for a reason no reconnect can fix. shard is what this shard identified with, which 4010 and 4011 need to be actionable.

  • Requested
  • IdentifyTooLarge(bytes: Int)

    The IDENTIFY this config builds is bytes long, over Discord’s 4096-byte payload limit. A presence or a token to shrink, not a connection to retry.

  • NoProgress(attempts: Int)

    attempts connections in a row ended before READY, and the next one would identify. The shard stopped rather than keep spending from the identify budget on a failing dial. A resume loop never trips this.

Somewhere to dial: a bare host, no scheme and no path. Every URL Discord hands out has a scheme, so the two are different types and a wss://… cannot reach a slot that dials.

pub type Host =
  @internal Host
pub type Ignored {
  StaleConn(Conn)
  StaleTimer(Timer)
  StaleInflate(Stamp)
  OutOfPhase
  Terminal
}

Constructors

  • StaleConn(Conn)

    From a connection this shard has abandoned.

  • StaleTimer(Timer)

    From an arming this shard has superseded.

  • StaleInflate(Stamp)

    An inflate answer whose stamp is not the outstanding request: an adapter answering one twice, or echoing a stamp we never issued. A reset clears the request, so an answer it superseded lands as OutOfPhase.

  • OutOfPhase

    Meaningful input, wrong phase: a duplicate HELLO, a Start while already running, an inflate answer nobody asked for.

  • Terminal

    The shard has halted. Nothing will change that.

Reassembly for a compressed transport.

pub type Inbound {
  Inbound(
    buffer: BitArray,
    inflating: option.Option(Stamp),
    pending: List(BitArray),
  )
}

Constructors

  • Inbound(
      buffer: BitArray,
      inflating: option.Option(Stamp),
      pending: List(BitArray),
    )

    Arguments

    buffer

    Bytes on this connection that are not yet a complete payload. Only intake writes it: a reset replaces the whole record instead.

    inflating

    The stamp of the outstanding inflate request, if any. At most one is in flight: two completing out of order would apply sequences backwards.

    pending

    Complete payloads that arrived while an inflate was outstanding, oldest first. Holding them in buffer would glue two payloads into one.

Why an inflate could not be answered with a payload. Neither is survivable inside a connection: the next payload would be read against a codec context that no longer matches the stream.

pub type InflateFailure {
  ContextDesynchronised(detail: String)
  NoInflateContext
}

Constructors

  • ContextDesynchronised(detail: String)

    The inflate itself failed. detail is the host’s own message, for a log and nothing else.

  • NoInflateContext

    The host has no inflate context to answer with. A transport that negotiated compression with nothing to inflate with.

Everything that can happen to a shard. Every field is plain data, so an input log is a data file and replay is a fold.

pub type Input {
  Start
  Opened(conn: Conn)
  OpenFailed(conn: Conn, failure: DialFailure)
  Frame(conn: Conn, text: String)
  Bytes(conn: Conn, data: BitArray)
  Inflated(
    conn: Conn,
    stamp: Stamp,
    result: Result(String, InflateFailure),
  )
  Closed(conn: Conn, code: option.Option(Int))
  Fired(timer: Timer, stamp: Stamp)
  IdentifySlotGranted(conn: Conn)
  Command(command: command.Command)
  Stop
}

Constructors

  • Start

    Begin. A no-op unless the phase is Idle.

  • Opened(conn: Conn)

    The transport established the WebSocket, upgrade complete.

  • OpenFailed(conn: Conn, failure: DialFailure)

    The transport could not establish it. Also the right input for a socket that died before the upgrade finished.

  • Frame(conn: Conn, text: String)

    A text frame arrived, carrying exactly one complete gateway payload. Under NoCompression this is the only frame input.

  • Bytes(conn: Conn, data: BitArray)

    One whole binary WebSocket message under a compressed transport, with any continuation frames already joined into it. It is part of a payload or the end of one, never several: a payload ends on a message boundary, and the core buffers until one does.

  • Inflated(
      conn: Conn,
      stamp: Stamp,
      result: Result(String, InflateFailure),
    )

    The answer to an Output.Inflate. Either failure ends the connection.

  • Closed(conn: Conn, code: option.Option(Int))

    The socket closed. None means the transport died with no close frame. A transport that synthesises 1006 for that case is saying the same thing.

  • Fired(timer: Timer, stamp: Stamp)

    A timer fired. stamp must be the value from the ArmTimer that armed it; a mismatch means the firing is stale and is dropped.

  • IdentifySlotGranted(conn: Conn)

    The identify queue granted this connection a slot.

  • Command(command: command.Command)

    The host wants to send a gateway command.

  • Stop

    Close cleanly and stop. Terminal.

What the next connection will do when HELLO arrives.

pub type Intent {
  Identify
  Resume(session: Session)
}

Constructors

  • Identify
  • Resume(session: Session)
pub type Notice {
  Ignored(what: Ignored)
  Zombie(unacked: Int, quiet: Bool)
  AwaitingIdentifySlot
  InvalidSession(resumable: Bool)
  SpuriousAck
  UndecodableFrame(reason: Undecodable)
  ResumeHostRejected(host: Host)
  CommandQueued(depth: Int)
  CommandDropped(depth: Int)
  PayloadTooLarge(bytes: Int)
  BufferOverflow(bytes: Int)
  InflateFailed(why: InflateFailure)
}

Constructors

  • Ignored(what: Ignored)

    An input that did not apply. Typed, so the negative paths are assertable.

  • Zombie(unacked: Int, quiet: Bool)

    The zombie detector fired. unacked is how many heartbeats went unacknowledged; quiet is whether anything at all was arriving.

  • AwaitingIdentifySlot

    Sitting in Queued. At max_concurrency: 1 a large fleet spends real time here.

  • InvalidSession(resumable: Bool)

    Discord sent op 9. resumable is the d field.

  • SpuriousAck

    An op 11 with no heartbeat outstanding: the gateway is acknowledging beats we did not send.

  • UndecodableFrame(reason: Undecodable)

    A frame we could not decode, or an opcode we do not model. The connection is fine; the frame is not.

  • ResumeHostRejected(host: Host)

    Repeated dial failures against the session’s resume host; falling back to the configured one. A bad resume host must not wedge a shard.

  • CommandQueued(depth: Int)

    depth is the size of the pending queue after the change.

  • CommandDropped(depth: Int)
  • PayloadTooLarge(bytes: Int)

    An outbound payload was over Discord’s 4096-byte limit and was not sent. Sending it earns close 4002, which reads as “we sent malformed JSON”.

  • BufferOverflow(bytes: Int)

    The reassembly buffer went past max_payload_bytes with no complete payload in it.

  • InflateFailed(why: InflateFailure)

What the caller must do. Perform them in order and perform all of them.

pub type Output {
  Open(conn: Conn, host: Host, path: String)
  Send(frame: frame.Outbound)
  Close(code: Int)
  Drop
  ArmTimer(timer: Timer, in_ms: Int, stamp: Stamp)
  CancelTimer(timer: Timer)
  RequestIdentifySlot(conn: Conn)
  ReleaseIdentifySlot(conn: Conn)
  Inflate(conn: Conn, stamp: Stamp, bytes: BitArray)
  ResetInflater(conn: Conn)
  Emit(event: Event)
  Note(notice: Notice)
}

Constructors

  • Open(conn: Conn, host: Host, path: String)

    Open a WebSocket to wss://<host><path>, spelling the host with host_to_string. path already carries ?v=, &encoding=json and &compress=; the adapter must not modify it.

  • Send(frame: frame.Outbound)

    Send frame.text as a text frame. Fire and forget: an adapter whose send fails reports Closed(conn, None) and never throws. The opcode rides along for logging, so nothing has to parse the payload back.

  • Close(code: Int)

    Send a close frame with this code, then tear the socket down. Only 1000, which invalidates the session, or 4000, which keeps it resumable.

  • Drop

    Abandon the transport with no close frame: the peer already closed, or nothing was ever connected.

  • ArmTimer(timer: Timer, in_ms: Int, stamp: Stamp)

    Arm timer to fire in in_ms, replacing any timer already armed under the same name, then deliver Fired(timer, stamp) with this exact stamp. in_ms is always in [0, 600_000].

  • CancelTimer(timer: Timer)

    Cancel timer. Cancelling an unarmed timer is a no-op, and cancellation need not be atomic: the core tolerates a Fired that arrives anyway.

  • RequestIdentifySlot(conn: Conn)

    Ask the identify queue for a slot for this connection.

  • ReleaseIdentifySlot(conn: Conn)

    Tell the identify queue this connection no longer needs its slot. Idempotent; releasing a slot that was never granted is a no-op.

  • Inflate(conn: Conn, stamp: Stamp, bytes: BitArray)

    Inflate exactly one complete compressed payload and answer with Inflated(conn, stamp, _). At most one is ever outstanding.

  • ResetInflater(conn: Conn)

    Discard the inflate context for conn and start a fresh one. Emitted alongside every Open: zlib state does not carry across connections.

  • Emit(event: Event)

    A protocol event for the host application.

  • Note(notice: Notice)

    Diagnostics, never protocol-significant.

Where the shard is in the connection lifecycle, carrying exactly the data that exists at that point.

pub type Phase {
  Idle(intent: Intent)
  Waiting(intent: Intent)
  Dialing(intent: Intent)
  Greeting(intent: Intent)
  Queued(beat: Beat)
  Identifying(beat: Beat)
  Resuming(beat: Beat, session: Session)
  Live(beat: Beat, session: Session, budget: Budget)
  Dead(reason: close.Reason)
  Stopped
  Unusable(bytes: Int)
  Exhausted(attempts: Int)
}

Constructors

  • Idle(intent: Intent)

    Nothing has started; Start is the only input that does anything. intent is Identify from new and Resume from resuming.

  • Waiting(intent: Intent)

    No socket, Reconnect armed. intent is what the next connection does when HELLO arrives. Initial connect, backoff and both pauses land here.

  • Dialing(intent: Intent)

    Open emitted, awaiting Opened or OpenFailed. The handshake watchdog covers this window, so a transport hung in TLS cannot wedge the shard.

  • Greeting(intent: Intent)

    Socket up, awaiting HELLO (op 10). Watchdog still armed.

  • Queued(beat: Beat)

    HELLO seen, waiting for an IDENTIFY slot. No Intent, because a RESUME never queues. The handshake watchdog is cancelled here: at max_concurrency: 1 the sixteenth shard waits 75 seconds for a slot.

  • Identifying(beat: Beat)

    IDENTIFY is on the wire, awaiting READY. There is no session and provably no sequence number.

  • Resuming(beat: Beat, session: Session)

    RESUME is on the wire. Replayed dispatches arrive before RESUMED, advance session.seq, and each re-arms the handshake watchdog.

  • Live(beat: Beat, session: Session, budget: Budget)

    READY or RESUMED has landed.

  • Dead(reason: close.Reason)

    Terminal. A close code no reconnect can fix.

  • Stopped

    Terminal. The host asked to stop.

  • Unusable(bytes: Int)

    Terminal. The IDENTIFY this config builds is over Discord’s 4096-byte payload limit, so no connection can get past the handshake.

  • Exhausted(attempts: Int)

    Terminal. attempts connections in a row ended before READY, which is a shard that is broken rather than one that is unlucky.

pub type Properties =
  frame.Properties

Everything a RESUME needs. resume_host is the node READY named, or the configured host when READY named none.

pub type Session {
  Session(id: String, resume_host: Host, seq: Int)
}

Constructors

  • Session(id: String, resume_host: Host, seq: Int)

One shard’s complete protocol state.

pub type Shard {
  Shard(
    config: Config,
    phase: Phase,
    attempts: Int,
    stamps: Stamps,
    pending: List(command.Command),
    inbound: Inbound,
    rng: rng.Rng,
  )
}

Constructors

  • Shard(
      config: Config,
      phase: Phase,
      attempts: Int,
      stamps: Stamps,
      pending: List(command.Command),
      inbound: Inbound,
      rng: rng.Rng,
    )

    Arguments

    attempts

    Consecutive failed connection attempts. Reset to 0 by READY or RESUMED and by nothing else, so an accept-then-close loop still backs off.

    pending

    Commands the host asked to send that have not gone out. Outlives a connection, unlike the budget.

    inbound

    Reassembly for a compressed transport. Empty under NoCompression.

pub type Sharding =
  frame.Sharding

Identifies one arming of one timer, and one inflate request. A cancel cannot recall a delivered message, so a stale fire is recognised when it lands.

pub type Stamp {
  Stamp(Int)
}

Constructors

  • Stamp(Int)

The current stamp for each stampable thing, and the counter they come from. A record and not a Dict: a new timer is a compile error at every site.

pub type Stamps {
  Stamps(
    next: Int,
    conn: Conn,
    heartbeat: Stamp,
    handshake: Stamp,
    reconnect: Stamp,
    commands: Stamp,
  )
}

Constructors

The result of one step.

pub type Step {
  Step(shard: Shard, outputs: List(Output))
}

Constructors

The four timers the protocol needs. All are one-shot and re-armed on fire, so a leaked repeater is unrepresentable.

pub type Timer {
  Heartbeat
  Handshake
  Reconnect
  Commands
}

Constructors

  • Heartbeat

    Send the next heartbeat, or notice the connection is a zombie.

  • Handshake

    The connection has taken too long to reach READY or RESUMED.

  • Reconnect

    Dial again.

  • Commands

    The 60-second command window rolled over.

Every number that is a glyde policy choice rather than a Discord rule.

pub type Tuning {
  Tuning(
    handshake_timeout_ms: Int,
    backoff_base_ms: Int,
    backoff_max_ms: Int,
    identify_backoff_max_ms: Int,
    no_progress_limit: Int,
    missed_ack_limit: Int,
    command_limit: Int,
    command_queue_max: Int,
    max_payload_bytes: Int,
  )
}

Constructors

  • Tuning(
      handshake_timeout_ms: Int,
      backoff_base_ms: Int,
      backoff_max_ms: Int,
      identify_backoff_max_ms: Int,
      no_progress_limit: Int,
      missed_ack_limit: Int,
      command_limit: Int,
      command_queue_max: Int,
      max_payload_bytes: Int,
    )

    Arguments

    handshake_timeout_ms

    No READY or RESUMED this long after the dial and the handshake is wedged. Does not run while waiting for an identify slot.

    backoff_max_ms

    Ceiling on the reconnect ladder when the next connection will RESUME. Only downtime is at stake there, because a RESUME is unmetered.

    identify_backoff_max_ms

    Ceiling on the ladder when the next connection must IDENTIFY. Discord allows 1000 a day and the penalty is a token reset: half jitter spends 86_400_000 / (0.75 * cap) a day, so stay above 115_200. No timer is armed for longer than ten minutes, so a ceiling past that is ten minutes.

    no_progress_limit

    Connection attempts in a row that never reach READY or RESUMED before the shard halts instead of redialling. Our choice, not Discord’s: below 1 is 1, and the ladder cannot count past 32.

    missed_ack_limit

    Consecutive heartbeats with no op 11 before the connection is a zombie. Discord’s rule is one, and anything below 1 is treated as 1.

    command_limit

    Outbound commands per 60s window. Discord’s hard limit is 120 and exceeding it is close 4008; the gap is heartbeat headroom.

    command_queue_max

    Commands buffered while the window is closed or the shard is not live. Past this, the oldest is dropped with a Notice. Below 0 is 0.

    max_payload_bytes

    Hard cap on the bytes held between messages, so a gateway that stops sending the sync-flush suffix cannot grow the buffer forever. A payload that is complete when it arrives is delivered whatever its size.

Everything that can arrive on a healthy socket and still be unusable. A value and not a sentence, so a host can match on one and a test can assert which it got.

pub type Undecodable {
  FrameUnreadable(why: frame.Unreadable)
  UnknownOpcode(op: Int)
  ReadyIncomplete(why: ready.ReadyRejected)
  ReadyWithoutResumeHost
  BinaryFrameWithoutCompression
}

Constructors

  • FrameUnreadable(why: frame.Unreadable)

    The envelope reader could not read the frame. Carried as frame’s own value rather than restated here: a second copy of these names is a second thing that has to stay true.

  • UnknownOpcode(op: Int)

    A well formed frame carrying an opcode glyde does not model. Discord adding one must not kill a session.

  • ReadyIncomplete(why: ready.ReadyRejected)

    READY arrived without the three fields a session needs.

  • ReadyWithoutResumeHost

    READY’s resume_gateway_url had no host in it. The session is kept and the next RESUME goes to the configured host: a missing hint is not worth one of the 1000 identifies a day.

  • BinaryFrameWithoutCompression

    A binary frame with no transport compression negotiated. Nothing can be done with the bytes, and dropping them silently looks like a quiet gateway.

pub type Why {
  DialFailed(failure: DialFailure)
  PeerClosed(code: option.Option(Int))
  ZombieConnection
  HandshakeStalled
  HandshakeUnreadable
  ServerRequested
  SessionInvalidated
  TransportCorrupt
}

Constructors

  • DialFailed(failure: DialFailure)
  • PeerClosed(code: option.Option(Int))
  • ZombieConnection
  • HandshakeStalled

    The handshake watchdog expired with nothing to show for it.

  • HandshakeUnreadable

    Discord answered the handshake and the answer could not be read. Nothing stalled: the accompanying UndecodableFrame says what was wrong with it.

  • ServerRequested
  • SessionInvalidated
  • TransportCorrupt

Values

pub fn config(
  token token: String,
  intents intents: intents.Intents,
) -> Config

The mandatory fields, with everything else defaulted.

pub fn conn(shard: Shard) -> Conn

The connection the shard will hear from.

pub fn default_host() -> Host

Discord’s front door, and what config fills Config.host with. GET /gateway/bot may name another one; a RESUME goes to the host READY named.

pub fn describe(notice: Notice) -> String

One line per diagnostic, for a host that just wants to log them. Every variant carries its values too, so acting on one never means parsing this.

pub fn describe_halt(reason: Halt) -> String

Why the shard stopped, and for a fatal close what to do about it.

pub fn describe_timer(timer: Timer) -> String
pub fn describe_why(why: Why) -> String

Why the connection ended and a reconnect is armed. A host printing a reconnect without this says a bot is flapping and never says what is wrong with it.

pub fn disarm(
  deadlines: List(Deadline),
  timer: Timer,
) -> List(Deadline)

Forget every arming of timer. ArmTimer replaces rather than adds, so an adapter runs this before recording a new one, and again on CancelTimer.

pub fn host_of(url from: String) -> Result(Host, Nil)

The host of a gateway URL, scheme and path and query dropped. Error(Nil) when there is nothing in it to dial.

pub fn host_to_string(host: Host) -> String

For a log line or a URL an adapter is building. The core hands out Host everywhere else, so this is the one direction that needs asking for.

pub fn is_terminal(shard: Shard) -> Bool

True once the shard has halted, however it got there. An adapter’s loop exits here.

pub fn large_threshold(value: Int) -> frame.LargeThreshold

Discord’s rule, not ours: large_threshold is 50 to 250, and a value outside it is clamped here rather than at the handshake.

pub fn large_threshold_value(
  threshold: frame.LargeThreshold,
) -> Int

The number Config.large_threshold holds, after the clamp.

pub fn new(config config: Config, seed seed: Int) -> Shard

A fresh shard in Idle with nothing armed. Feed it Start. Keep seed below 2^31 and different per shard, or shards jitter in lockstep.

pub fn properties(
  os os: String,
  browser browser: String,
  device device: String,
) -> frame.Properties

Fills Config.properties. Discord logs these and acts on none of them.

pub fn reseed(shard shard: Shard, seed seed: Int) -> Shard

A shard whose jitter starts again from seed, everything else untouched. Same rule as new: below 2^31, and different for every shard of a fleet.

pub fn resuming(
  config config: Config,
  seed seed: Int,
  session session: Session,
) -> Shard

A shard that will RESUME instead of IDENTIFY on its first connection, for a host that persisted a session across a process restart.

pub fn session(shard: Shard) -> option.Option(Session)

The session, if there is one to resume with, including while disconnected. Persist this across a process restart and hand it to resuming.

pub fn shard_count(sharding: frame.Sharding) -> Int

How many shards the fleet has.

pub fn shard_index(sharding: frame.Sharding) -> Int

This shard’s 0-based place in the fleet.

pub fn sharding(
  index index: Int,
  count count: Int,
) -> Result(frame.Sharding, frame.ShardingError)

sharding(index: 0, count: 1) is an unsharded bot, which Discord reads as sending no shard array at all. index is 0-based and must be below count, so a fleet the gateway would answer with close 4010 is refused here rather than at the handshake.

pub fn step(shard: Shard, input: Input) -> Step

Advance the machine by one input. One that does not apply produces a single Note(Ignored(_)); the same pair always yields the same Step.

Search Document