Clients
Ursula speaks plain HTTP and Server-Sent Events. There is no required client library. Any HTTP client in any language works.
The examples elsewhere in these docs use curl because it's universal. The same routes, headers, and query parameters apply to every other client.
Minimal examples
# create a bucket and stream
curl -X PUT http://127.0.0.1:4437/demo
curl -X PUT http://127.0.0.1:4437/demo/hello
# append
curl -X POST http://127.0.0.1:4437/demo/hello \
-H 'Content-Type: application/octet-stream' \
--data-binary 'hello world'
# catch-up read
curl 'http://127.0.0.1:4437/demo/hello?offset=-1'
# live tail
curl 'http://127.0.0.1:4437/demo/hello?offset=-1&live=sse'import requests
base = "http://127.0.0.1:4437"
requests.put(f"{base}/demo")
requests.put(f"{base}/demo/hello")
requests.post(
f"{base}/demo/hello",
headers={"Content-Type": "application/octet-stream"},
data=b"hello world",
)
# catch-up read
resp = requests.get(f"{base}/demo/hello", params={"offset": -1})
print(resp.content)
# live tail with SSE
with requests.get(
f"{base}/demo/hello",
params={"offset": -1, "live": "sse"},
stream=True,
) as r:
for line in r.iter_lines():
if line:
print(line.decode())const base = "http://127.0.0.1:4437";
await fetch(`${base}/demo`, { method: "PUT" });
await fetch(`${base}/demo/hello`, { method: "PUT" });
await fetch(`${base}/demo/hello`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: "hello world",
});
// catch-up read
const data = await (await fetch(`${base}/demo/hello?offset=-1`)).text();
// live tail with native EventSource
const es = new EventSource(`${base}/demo/hello?offset=-1&live=sse`);
es.addEventListener("data", (e) => console.log(e.data));From a browser
The examples above assume same-origin access. A page served from a different origin than the gateway needs the gateway to allow that origin — see cross-origin reads:
ursula gateway ... --cors-allowed-origin https://app.example.com
Two things to know once it is on:
-
Read your continuation headers. The gateway sends
Access-Control-Expose-Headers: *, soStream-Next-Offsetand friends are readable. Without exposure a browser can read one page and never advance, which looks like the stream ending. If afetchsucceeds butresponse.headers.get("stream-next-offset")isnull, the origin is not allowing your origin. -
EventSourcecannot sendAuthorization. It has no header API, so it only works againstpublic_readstreams. For a private live tail, usefetchand read the body:const response = await fetch(`${base}/${bucket}/hello?offset=-1&live=sse`, { headers: { Authorization: `Bearer ${token}` }, }); const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();A subscription ends when its credential expires, and says so: the final frame is
event: credential-expired. Treat it as a reconnect signal rather than end of stream — refresh the token and re-read from the lastStream-Next-Offsetyou saw. Any other termination deserves the same handling.
Notes for client implementers
- After every read and append, the server returns
Stream-Next-Offset. Track it. Use it as theoffsetquery parameter on the next read. Don't construct offsets manually. They're opaque. - For binary streams over SSE,
dataevents carry raw base64 text and the response includesStream-Sse-Data-Encoding: base64. Decode the data event first, then interpret the bytes usingStream-Data-Content-Type. See Binary SSE. - For exactly-once writes, send
Producer-Id,Producer-Epoch,Producer-Seqheaders and retry on network errors. The server deduplicates. See Exactly-once writes. - For conditional writes, use
Stream-Seqto enforce ordering from one logical writer. For JSON streams that need a compare-current-tail guard across writers, use Ursula'sStream-Record-Matchextension. See Conditional writes.