WebSocket streaming API¶
The WebSocket streaming API lets a client subscribe to a live Center table and receive its contents as a snapshot followed by a stream of row changes. It is a text/JSON protocol designed for browser-facing live views: conflated and simple, not a high-throughput firehose.
Enable the server first
The WebSocket server opens a port only when ami.websocket.port is set; it ships disabled. See Configuration.
Looking for a different surface?
Two other messaging surfaces are easy to confuse with this one, and neither is documented in this section:
- To publish data into AMI from an application, use the Relay's realtime streaming port (
ami.port, default3289), a pipe-delimited socket protocol rather than a WebSocket. Most applications reach it through the client libraries rather than speaking it directly. - The RPC WebSocket (
ami.rpc.ws.*, default port33334) carries Language Server and Debug Adapter traffic for development tooling, not table data.
Connect¶
The path selects the target: ws://host:3410/center subscribes against the Center named center; a bare / defaults to center. The target may be a Center (streams its own tables) or a Gateway (fans the subscription out across its Centers and streams the merged result). An unknown name closes the connection with unknown component: X (existing: ...).
With a keystore configured the URL becomes wss://….
Authenticate¶
Authentication is HTTP Basic on the connection handshake. Because browsers cannot set an Authorization header on a WebSocket handshake, embed the credentials in the URL:
A browser does not send these credentials on its first attempt. It sends them only after the server rejects the handshake with 401 and a WWW-Authenticate: Basic challenge, which the server does when an authentication plugin is configured and the credentials are missing or refused.
If the server is configured with ami.websocket.auth.plugin.class=DISABLED (the shipped default), connections are accepted without a credential check, under the username they send or as anonymous if they send none, and no challenge is sent, so a browser never sends URL credentials. See Configuration for the auth postures.
A Gateway path still needs credentials when the Gateway authenticates subscribers, which it does by default: the server forwards the handshake credentials as the Gateway login. Without them the connection opens, but a subscribe is answered with subscribe failed: authentication required; …. A browser client reaches a Gateway path only when an authentication plugin is configured, so that the challenge makes the browser send its URL credentials. A non-browser client that sends the Authorization header itself works with DISABLED too. A login the Gateway refuses is reported as an error starting upstream:. Center paths take no login.
Subscribe¶
Send a JSON text frame:
| Field | Required | Meaning |
|---|---|---|
action |
yes | "subscribe" |
sql |
yes | SELECT <cols\|*> FROM <table> [WHERE <expr>]. * or omitted columns = all columns. One table, no joins. |
id |
no | A client-chosen id echoed back on the subscribed and unsubscribed acknowledgements, for correlation. Of the error frames, only subscribe failed: errors echo it. |
oof |
no | Focus filtering; default false. See Focus filtering. |
The WHERE is an AMI expression, not ANSI SQL
Equality is ==, and string literals use double quotes: SYM == "IBM", not SYM = 'IBM'. Inside the JSON string those double quotes are escaped as \".
An ANSI-style WHERE SYM = 'IBM' does not fail, and it does not filter to an empty set. A single = is not an equality operator here, so the clause is not a boolean, and a WHERE the Center cannot evaluate is not reported: the subscription delivers every existing row unfiltered, then receives no further changes. No error frame is sent. Test a filtered subscription against a row it should exclude, not only against one it should include.
A subscription is acknowledged even when it cannot work
The server validates only that action is known and that sql is a SELECT. Everything else is accepted and then silently ignored:
- An unknown table returns
subscribedandsnapshot, then never sends a row. - An unknown column is dropped from the rows you receive; the other columns arrive normally.
- A
WHEREthat is not a boolean expression delivers every existing row unfiltered, then no further changes.
A subscribed acknowledgement therefore means your frame parsed, not that your subscription matches anything. Confirm a new subscription by the rows it delivers, and by an update you make after it loads.
Unsubscribe¶
Messages you receive¶
Every server frame carries a type:
type |
Shape | Meaning |
|---|---|---|
subscribed |
{"type":"subscribed","id":"s1","table":"QUOTES"} |
Your subscription is active. |
unsubscribed |
{"type":"unsubscribed","id":"s1","table":"QUOTES"} |
Your unsubscribe was processed. Sent even if you were not subscribed to that table. |
error |
{"type":"error","message":"bad request: …"} |
Something failed. The message prefix says what: bad request: (your frame was rejected: an unknown action, or sql that is not a SELECT), subscribe failed: (the subscription could not be set up), or upstream: (the upstream Center or Gateway reported an error). Only subscribe failed: echoes your id. A subscription that parses but cannot match anything is not reported here. |
status |
{"type":"status","connected":true\|false} |
The upstream Center connected or disconnected. |
snapshot |
{"type":"snapshot","tables":["QUOTES"]} |
Marks the end of the initial load: the initial matching rows have already arrived as add entries in the preceding updates frames. |
updates |
see below | A batch of row changes. |
An updates frame batches per-row changes:
actionisadd,update, ordelete.objectIdidentifies the row across updates: use it as the key in your client-side map. It is the same on a row'sadd, everyupdate, and itsdelete. For a row published through a Relay with its own object id, it is that id; otherwise it is the row'sidas a string. It is not your primary-key column: don't try to look a row up by itsSYM/ORDER_ID.rowmaps column name → value. On anupdateit contains only the changed columns; adeletecarriesrow:{}. Byte-array columns are Base64-encoded.
Most error frames cannot be traced back to a subscription
A bad request: or upstream: error carries only type and message: the id you sent is not echoed, and there is no table field either. On a connection holding several subscriptions you cannot tell from such an error which frame was rejected, so validate a frame before sending it rather than relying on the reply to identify it. A subscribe failed: error does carry the id of the subscribe that failed.
First messages on connect¶
Subscribing produces an opening sequence, then live changes:
The status frame (upstream connected) and the subscribed acknowledgement come first, in either order: they are sent independently, so do not depend on which arrives first. The initial matching rows follow as add entries in one or more updates frames, and the snapshot frame comes last: when it arrives, your view of the table is complete. A table with no matching rows sends no updates frame before the snapshot. After that, only live changes stream.
Treat everything before snapshot as the initial state. A write that reaches the server before the snapshot completes is merged into the initial rows as an add; any later write arrives in a later updates frame. To tell rows that were already there from rows added since, note whether the snapshot frame has arrived.
Conflation¶
Row changes are coalesced per row and flushed to you on a short interval (ami.websocket.conflate.interval.ms, default 100 ms). If a row changes several times within one interval, you receive one merged entry, carrying only the fields that changed since the last flush. This keeps a fast-moving table manageable for a browser; it also means you see conflated state, not every intermediate tick.
Focus filtering (oof)¶
oof controls whether the Center keeps your filtered result accurate as rows change:
oof:true: the Center re-checks yourWHEREwhenever an update changes a column it reads. When a row starts matching you receive anadd; when it stops matching you receive adelete. Your client always holds exactly the matching rows. This costs the Center a per-update re-check.oof:false(default): theWHEREis evaluated only when a row is inserted or snapshotted. So a newly inserted matching row is delivered as anadd; but a row that was already in the table and later changes into the filter is never added, and a row that changes out of the filter is never removed (nodelete, so it goes stale on your side). Cheaper, but the view is not kept filter-accurate.
Use oof:true whenever the point of the filter is a set whose membership changes: open orders, breached limits, symbols past a threshold. See the filtered view and limit-breach alert use cases.
Limits in this version¶
- Subscribe only: no queries or writes over the WebSocket. Use REST for those.
- One table per subscription, no joins; one subscription per table per connection. This is not enforced: a second
subscribeto the same table on one connection is accepted, but the connection then emits a spuriousdeleteframe with an emptytableand a nonsense id, and the two filters end up merged. Don't rely on the second subscribe being rejected; avoid it. - Message size: an inbound frame over 1 MB, or a message split across frames over
ami.websocket.max.message.bytes(default 1 MB), closes the connection with WebSocket status1009and reasonmessage too large. No error frame is sent first. - Liveness: the server PINGs idle connections; browsers answer automatically, so a healthy receive-only client is never dropped.
Is the server running?¶
There is no documented endpoint that reports whether the WebSocket server is enabled; the server-side confirmation is a startup log line an external client usually cannot see. From a client, the only signal is the connection itself: if ami.websocket.port is unset the port is closed and the connection is refused (or times out). Treat a refused connection as "server not enabled" (see Configuration), distinct from an accepted connection that then closes with unknown component: … (server up, wrong path).
First-connection warm-up¶
The first subscription after a process restart can take up to ~2 seconds before the status frame arrives, while the server warms up; subsequent connections are fast (~tens of ms). Don't treat a couple of seconds of initial silence on a fresh connection as failure: a client that reconnect-loops on a short timeout will thrash on first load. Give the first connect a generous timeout.
A ready-made test client¶
A working reference client ships with the code (reference-client.html): open it in a browser, set the URL to ws://localhost:3410/center, connect, and subscribe with a SELECT. It is the fastest way to confirm the server is up before you write your own client.