Push Changes to the Browser
A customer watches their order page and sees the status change the moment it is approved; staff see new messages about an order as they are written. The application already announces every change with events. Pushing them to clients takes two declarations: which events go to which keys, and which routes serve the stream of a key.
§A first stream
The shop lets a customer and the support staff exchange messages about an order. support.messages holds them: each message has an order, a sender and a text. It is a new
component, and this manifest is all of it:
# components/support.messages/manifest.toa.yaml
name: messages
namespace: support
entity:
properties:
order:
type: string
sender:
type: string
text:
type: string
operations:
create:
forward: transit
query: false
input:
type: object
properties:
order: { type: string }
sender: { type: string }
text: { type: string }
required: [order, sender, text]
realtime:
created:
key: order
expose: [id, sender, text]
exposition:
/:
io:output: [id]
POST: create
/of/:order/stream:
GET:
realtime:stream: order create forwards to the inherited transit, as the create of the generated notes does; see Use the Inherited Operations.
The realtime section says: when a message is created, send the event to the stream of the key
held in its order property, with the properties id, sender and text.
The route says: GET /support/messages/of/<order>/stream/ answers with the stream of the key that :order holds.
The Context says where streams are kept. Add the annotation; the
Redis that npm run dock starts will do:
# context.toa.yaml
realtime:
streams: redis://localhost:6379 A client opens the stream and keeps the connection:
GET /support/messages/of/4c4759e6f9c74da989d64511df42d6f4/stream/ HTTP/1.1
accept: application/json HTTP/1.1 200 OK
content-type: multipart/json; boundary=cut
--cut
"ACK"
--cut
{"event":"token","data":"MTcyNjY4OTc0MjAwMC0w"} When anyone posts a message for that order, a part arrives on the open connection:
--cut
{"event":"support.messages.created","data":{"id":"a7edded6b2ab47a0aca9508cc4da4138","sender":"b70a…f4db","text":"Has it shipped?"}}
--cut
{"event":"token","data":"MTcyNjY4OTc0NTUxMi0w"} Try it with two terminals, with anonymous: true on both routes for the experiment. -N makes
curl print parts as they arrive:
$ npm run restart
$ curl -N http://localhost:8000/support/messages/of/4c4759e6f9c74da989d64511df42d6f4/stream/ $ curl http://localhost:8000/support/messages/ -H 'content-type: application/json'
-d '{"order": "4c4759e6f9c74da989d64511df42d6f4", "sender": "b70a7dbc", "text": "Has it shipped?"}' §Routing events to keys
The realtime section of a manifest is keyed by event label. Each entry is a route for that
event:
realtime:
created:
key: order
expose: [id, sender, text]
updated:
key: order
expose: [id, text] | Key | Meaning |
|---|---|
key | Where the event goes: a property of the event, a literal, or a list of them. Required. |
expose | The properties of the event the stream carries. Required. |
The event is what the component publishes for its entity: the event’s payload, or the entity
state for an event that declares no payload. The label is that of any event of the component,
including the ones every component has without declaring them: created, updated, deleted and sync (see Announce a Change). Routing sync is the usual way to
keep clients up to date with an entity. For store.orders, the component Create a Component writes, whose orders have a customer, a status and a total:
# components/store.orders/manifest.toa.yaml
realtime:
sync:
key: customer
expose: [id, status, total, VERSION] Only a component can route its own events. Realtime routes are not declared in the Context.
§Keys
A key is any value that names an audience: an order, a customer, a group the application makes up.
- A property —
key: order. The event goes to the stream of the value the property holds. - A property holding a list — the event goes to the stream of every value in it.
- Several keys —
key: [sender, recipient]. The event goes to each. - A literal —
key: ~staff, see Literal keys.
A value that begins with ~ is never used as a key; the event simply does not go there.
§What a stream exposes
expose is required, and a stream is given nothing it does not name. A realtime route without
it is refused when the manifest is read.
An event may carry internal properties. What leaves the application is the subset the route
lists — the same closed-by-default rule as io:output. Where
the two should match, share the list with a YAML anchor:
realtime:
sync:
key: customer
expose: &order [id, status, total, VERSION]
exposition:
/of/:customer:
io:output: *order
GET: enumerate Expose VERSION whenever clients update a copy from the stream; see Rules for the client.
§Serving a stream
realtime:stream on a GET answers with the stream of a key. Its value is the route variable
that holds the key:
exposition:
/of/:order/stream:
auth:role: staff
GET:
realtime:stream: order Whatever authorizes the route authorizes the stream. There is no separate permission model for
realtime. The directives on the route decide who may open it, exactly as they would for a GET that
returns data; a caller they refuse gets 401 or 403, and nothing is opened. Access rules are in Access.
The key is a value of the whole application, not of one component. Any component that routes an
event to the key 4c47…d6f4 writes to the same stream, whichever component serves the route. store.orders could route its sync by id, support.messages its created by order, and a
client reading the stream of one order identifier would receive both.
§An identity’s own stream
The id of an identity, whoever sends a request, is a key like any other, and the gateway serves every identity its own stream without a route being declared:
GET /realtime/b70a7dbca6b14a2eaac8a9eb4b2ff4db/ HTTP/1.1
authorization: Token …
accept: application/json Only the identity itself may open it. So to push something to a user, route an event by a property that holds the user’s identity id:
# components/store.orders/manifest.toa.yaml
realtime:
sync:
key: customer # holds the customer's identity id
expose: [id, status, total, VERSION] Every customer now receives the changes of their own orders on /realtime/<their id>/, along
with everything any other component routes to them. A client application typically opens this
one stream at sign-in and dispatches on the event name.
§Literal keys
A key that begins with ~ is a literal: the key itself, the same for every event, not a
property. Use it for an audience that is not a value of any entity — “all staff”, “everyone
watching the catalogue”.
# components/store.orders/manifest.toa.yaml
realtime:
created:
key: ~incoming
expose: [id, customer, total]
exposition:
/incoming/stream:
auth:role: staff
GET:
realtime:stream: ~incoming realtime:stream names the literal the same way, so its route needs no variable.
- The key is
~incoming, tilde included. - A value of a property is never taken for a literal. Whoever controls the values — a customer
naming something
~incoming— cannot route an event into a literal’s stream. - A literal’s stream is read only at a route that names it. A route variable that happens to
hold
~incomingis answered404. - Literals and properties mix:
key: [~incoming, customer].
§Pushing to a stream
Some state changes many times a second and is superseded each time: where a cursor is on a shared
board, where a shape is being dragged. No operation needs to commit it, and nobody wants it
replayed. realtime:push sends the request body to every reader of a key as it arrives, and
stores it nowhere:
# components/canvas.boards/manifest.toa.yaml
exposition:
/:id/stream:
auth:role: editor:{id}
GET:
realtime:stream: id
/:id/cursors:
auth:role: editor:{id}
POST:
realtime:push:
key: id # a route variable, or a literal, as realtime:stream names it
event: cursor # what a reader is sent it as
input: # a JSON schema of what may be pushed; required
type: object
properties:
x: { type: number }
y: { type: number }
tool: { type: string }
required: [x, y]
additionalProperties: false POST /canvas/boards/42/cursors/ HTTP/1.1
authorization: Token …
content-type: application/json
{"x":10.5,"y":3,"tool":"pen"} The gateway answers 204, and every reader of /canvas/boards/42/stream/ is sent:
{ "event": "cursor", "sender": "4c4759e6f9c14f3b8b2cc6e6f4c5b3a0", "data": { "x": 10.5, "y": 3, "tool": "pen" } } senderis the identity that pushed it, set by the gateway: a client cannot push as someone else. It is absent where the route admits an anonymous caller.- What is pushed is what
inputadmits. Anything else is answered400and pushed nowhere. Whatever the schema admits reaches every reader, so stateadditionalProperties: false. A body past 64 KiB is answered413. eventis neithertokennor a name with a dot, so a reader never takes a pushed event for a token or for a routed event.- Whatever authorizes the route authorizes the push. A request its directives refuse pushes
nothing. Limit how often a client may push with
io:throttle. - A pushed event is for whoever reads the key at that moment. It is not followed by a token, a reader that reconnects is not sent it, a reader whose connection does not keep up loses it, and it reaches the readers of its region only.
So a reader keeps the last one per sender. Whatever has to survive — a shape drawn, a comment —
is a change an operation commits, routed to the same key. A reader that has just opened the
stream reads that state, and is sent the next push.
§Reading a stream
A stream is a multipart response that stays open. After ACK, each part is one of four:
| Part | Shape | What to do |
|---|---|---|
| An event | { "event": "<namespace>.<component>.<label>", "data": { … } } | Apply it. |
| A pushed event | { "event": "<name>", "sender": "<identity>", "data": { … } } | Keep the last one per sender. |
| A token | { "event": "token", "data": "<token>" } | Keep the last one. |
| A heartbeat | the string heartbeat <time> | Nothing; it shows the stream is alive. |
eventis the event’s full name:support.messages.created. For a component without a namespace it isdefault.<component>.<label>. A pushed event is named by the route that pushes it.datais what the route exposes, or what was pushed.- A token is sent once when the stream opens and again after every event, but not after a pushed one.
- A heartbeat is sent every 16 seconds. A client that has seen nothing for noticeably longer should consider the connection lost.
The format of the parts follows accept, as everywhere.
In a browser, read the stream with fetch and a reader over the response body: the standard EventSource cannot send an authorization header, and this is not the Server-Sent Events
format.
§Reconnecting
A reader that lost its connection reconnects with the last token it was given, and is sent what was routed to the key since:
GET /support/messages/of/4c4759e6f9c74da989d64511df42d6f4/stream/?token=MTcyNjY4OTc0NTUxMi0w HTTP/1.1 The events of a key are kept while it has a reader, and for a while after its last reader left: 300 seconds unless the Context says otherwise. A token older than that is answered with whatever is left, which may be nothing. A client away for longer loads its copy again: Keeping a copy of a collection.
When the gateway stops, an open stream is ended with FIN. Treat FIN like a lost connection:
reconnect with the last token.
§Rules for the client
- Open the stream first, then read the current state. A stream starts when it is opened; what was routed to a key before is not sent.
- An event may arrive twice, and events arrive in no promised order. Applying one must be
harmless to repeat: keep the
VERSIONyou have and drop an event with an older one. - Access is checked when a stream is opened. A reader who loses access afterwards keeps the stream until it closes.
- Only what operations publish is sent. A record changed by a migration or removed by expiry produces no event. A copy learns of such a change when the collection is next read: see Keeping a copy of a collection.
The reasons are in Realtime.
§Keeping a copy of a collection
A client that shows a list — a customer’s orders, the board of the staff — keeps a copy of it. The copy is loaded with a collection stream and from then on kept by events:
- Open the stream of the key.
- Load the collection with a collection stream.
- Apply every event to the copy. Nothing is read for it.
How a collection is loaded is the subject of Stream Through the API: a window at a time as the user scrolls, or its newest entries and, at a later launch, what changed since. The token of that read is not the one of the realtime stream, which only reconnects it.
An event is never a reason to read. Not the collection, not its changes by the token, and not the entity the event is about: the event carries the change, and the client applies it. A client that answers an event with a request reads as often as the collection changes, for every reader at once, and for a change it was just told of.
So the event carries all the copy holds of an entity. Expose the same properties the read
answers, VERSION among them, with one list for both:
# components/store.orders/manifest.toa.yaml
realtime:
sync:
key: customer
expose: &order [id, customer, status, total, VERSION]
exposition:
/of/:customer:
auth:id: customer
GET:
endpoint: stream
io:output: *order
query:
criteria: status!=cancelled §An entity that left the collection
The collection the client reads is the one its request selects: here, the customer’s orders that
are not cancelled. An event is routed by its key and knows nothing of that query. When the
customer cancels an order, the event arrives all the same, with status: cancelled, and the
order is no longer of the collection.
Only the reader knows what it read: which route, and what criteria it added where the route’s are open. So the test is the client’s, and it lives beside the code that makes the request. That code answers a change for the copy, in the terms a collection stream uses for its parts:
// the client: what it reads, and what an event means for what it read
export function read (customer: string, token?: string) {
return stream(`/store/orders/of/${customer}/`, { token }) // the orders not cancelled
}
export function change (order: Order): { entry: Order } | { removed: string, VERSION: number } {
return order.status !== 'cancelled'
? { entry: order }
: { removed: order.id, VERSION: order.VERSION }
} stream stands for the client’s own reader of a multipart reply. The rest of the client applies
what change answers and knows no criteria.
- Expose every property the criteria read. An event that lacks one is given it; the client does not ask the server whether an entity still matches.
- Dropping what the copy does not hold changes nothing, so
changeneed not know whether the entity was of the collection before. - Remember what was dropped, with its
VERSION. Events arrive in no promised order, and an older state of a dropped entity may come after it left. It is ignored; a laterVERSIONthat matches is the entity again. - A read may bring an older state than an event did. One that was in flight when the event
arrived was answered before the change. Keep the higher
VERSION, as for any two events.
§One stream per interest
A client opens one stream per key it cares about. Where that would be many — every order on a board, every product in a list — give them a common key. The event carries the group it belongs to, and the group is the key:
# components/store.orders/manifest.toa.yaml
realtime:
sync:
key: [customer, ~incoming]
expose: [id, customer, status, total, VERSION] The customer hears about their orders on their own stream, and staff hear about all orders on one. Choosing keys is a modelling decision; the entities usually already say who the audiences are.
§Configuration
Streams are kept in Redis. The realtime annotation of the Context is required as soon as a
component declares realtime routes:
# context.toa.yaml
realtime:
streams: redis://realtime.store.example
expire: 300 | Key | Meaning | Default |
|---|---|---|
streams | The Redis URL, or a list of them. Required. | — |
expire | Seconds a key’s events are kept after its last reader left. | 300 |
expire is the window a client has to reconnect without loss.
With a list, the streams are spread over several Redis instances; each key is kept by one of them:
realtime:
streams:
- redis://realtime-0.store.example
- redis://realtime-1.store.example Keys are prefixed with the Context’s name, so several applications may share a Redis. Changing the list moves keys, and a reader whose key moved is not sent what it missed.
The address usually differs per environment:
realtime:
streams@local: redis://localhost:6379
streams@production: redis://redis.internal:6379 §Summary
| Where | Declaration | Meaning |
|---|---|---|
| Manifest | realtime.<event>: { key, expose } | Route an event of this component to keys. |
| Manifest | realtime:stream: <variable> | Serve the stream of the key in a route variable. |
| Manifest | realtime:stream: ~<literal> | Serve the stream of a literal key. |
| Manifest | realtime:push: { key, event, input } | Send a request body to the readers of a key. |
| Context | realtime: { streams, expire } | Where streams are kept, and for how long. |
| Built in | GET /realtime/:id/ | The stream of the identity :id, for that identity. |
A copy of a collection is loaded with a collection stream and kept by events. An event is applied, never answered with a read.