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'

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: *, so Stream-Next-Offset and friends are readable. Without exposure a browser can read one page and never advance, which looks like the stream ending. If a fetch succeeds but response.headers.get("stream-next-offset") is null, the origin is not allowing your origin.

  • EventSource cannot send Authorization. It has no header API, so it only works against public_read streams. For a private live tail, use fetch and 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 last Stream-Next-Offset you 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 the offset query parameter on the next read. Don't construct offsets manually. They're opaque.
  • For binary streams over SSE, data events carry raw base64 text and the response includes Stream-Sse-Data-Encoding: base64. Decode the data event first, then interpret the bytes using Stream-Data-Content-Type. See Binary SSE.
  • For exactly-once writes, send Producer-Id, Producer-Epoch, Producer-Seq headers and retry on network errors. The server deduplicates. See Exactly-once writes.
  • For conditional writes, use Stream-Seq to enforce ordering from one logical writer. For JSON streams that need a compare-current-tail guard across writers, use Ursula's Stream-Record-Match extension. See Conditional writes.