|
| 1 | +# HTTP Push + SSE Transport Profile (Loro Syncing Protocol) |
| 2 | + |
| 3 | +Transport profile version: 0. Extends `protocol.md` (Protocol v1) without changing the binary wire format. |
| 4 | + |
| 5 | +This document describes a transport mapping that uses: |
| 6 | + |
| 7 | +- **HTTP push** for client → server frames (request body carries a single protocol frame). |
| 8 | +- **SSE (Server‑Sent Events)** for server → client frames (each SSE event carries a single protocol frame). |
| 9 | + |
| 10 | +The goal is to reuse all message types, fragmentation rules, and semantics from `protocol.md`, while allowing two common exchanges to be handled as simple **HTTP request/response** pairs: |
| 11 | + |
| 12 | +- `JoinRequest` → `JoinResponseOk` / `JoinError` |
| 13 | +- `DocUpdate` / `DocUpdateFragment*` → `Ack` |
| 14 | + |
| 15 | +## Non‑Goals |
| 16 | + |
| 17 | +- Defining specific HTTP routes, parameters, or auth schemes. These are application decisions. |
| 18 | +- Providing reliable replay for SSE on reconnect (out of scope). Clients rejoin to recover. |
| 19 | + |
| 20 | +## Terminology |
| 21 | + |
| 22 | +- **Protocol frame**: the exact bytes produced by `encode(message)` from `loro-protocol` (and parsed by `decode(bytes)`). |
| 23 | +- **Session key**: an application-defined opaque identifier that binds one client's push requests to its SSE stream. |
| 24 | + - It can be carried via cookie, header, query parameter, etc. |
| 25 | + - The transport must ensure the same session key is used for both directions. |
| 26 | + |
| 27 | +## Core Invariants (Unchanged from `protocol.md`) |
| 28 | + |
| 29 | +- The binary protocol frame format is unchanged. |
| 30 | +- Message types and semantics are unchanged (`JoinRequest`, `DocUpdate`, fragments, `Ack`, `RoomError`, `Leave`, …). |
| 31 | +- **Max frame size is still 256 KiB**. Payloads that would exceed the limit MUST use fragmentation. |
| 32 | + |
| 33 | +## Frame Encodings |
| 34 | + |
| 35 | +### HTTP push (client → server) |
| 36 | + |
| 37 | +- Request body: a single protocol frame (binary). |
| 38 | +- Recommended headers: |
| 39 | + - `Content-Type: application/octet-stream` |
| 40 | + - `Content-Length: <frame size>` |
| 41 | +- Push responses MAY return a protocol frame (binary) when the exchange is naturally request/response: |
| 42 | + - `JoinRequest` → `JoinResponseOk` / `JoinError` |
| 43 | + - `DocUpdate` / fragments completing a batch → `Ack` |
| 44 | + |
| 45 | +For other push messages (e.g., `Leave`), the response body can be empty. |
| 46 | + |
| 47 | +### SSE pull (server → client) |
| 48 | + |
| 49 | +SSE is text-based, so each binary protocol frame is encoded as base64url. |
| 50 | + |
| 51 | +Event format: |
| 52 | + |
| 53 | +``` |
| 54 | +event: msg |
| 55 | +data: <base64url(protocol-frame)> |
| 56 | +
|
| 57 | +``` |
| 58 | + |
| 59 | +Notes: |
| 60 | + |
| 61 | +- **Exactly one protocol frame per SSE event**. |
| 62 | +- `data:` MAY be split across multiple lines; SSE concatenates them with `\n`. Implementations SHOULD either: |
| 63 | + - emit a single `data:` line, or |
| 64 | + - split into multiple `data:` lines and base64url‑decode after concatenation with `\n` removed. |
| 65 | + |
| 66 | +Base64url: |
| 67 | + |
| 68 | +- RFC 4648 "base64url" (`-` and `_` instead of `+` and `/`). |
| 69 | +- Padding (`=`) is OPTIONAL; decoders SHOULD accept both forms. |
| 70 | + |
| 71 | +## Session Binding |
| 72 | + |
| 73 | +Because HTTP requests are stateless and SSE is a long-lived stream, implementations MUST bind them with a session key. |
| 74 | + |
| 75 | +The transport profile does not dictate how, but it MUST satisfy: |
| 76 | + |
| 77 | +- A push request can be associated with exactly one logical session. |
| 78 | +- A server can route room broadcasts to all sessions that have joined that room. |
| 79 | + |
| 80 | +Security note: if the session key is sensitive, prefer cookie/header transport over query strings (URLs are often logged). |
| 81 | + |
| 82 | +## Request/Response Simplifications |
| 83 | + |
| 84 | +### Join handshake (`JoinRequest` → `JoinResponse*`) |
| 85 | + |
| 86 | +Recommended pattern: |
| 87 | + |
| 88 | +1. Client issues a push with a `JoinRequest` frame. |
| 89 | +2. Server responds in the same HTTP response body with: |
| 90 | + - `JoinResponseOk`, or |
| 91 | + - `JoinError`. |
| 92 | +3. After `JoinResponseOk`, server MAY send backfills (`DocUpdate` or fragments) over SSE. |
| 93 | + |
| 94 | +Rationale: SSE reconnections can drop in-flight frames; making join responses part of the push response avoids depending on SSE delivery guarantees. |
| 95 | + |
| 96 | +### Client-originated updates (`DocUpdate*` → `Ack`) |
| 97 | + |
| 98 | +Recommended pattern: |
| 99 | + |
| 100 | +- For `DocUpdate` (single frame): |
| 101 | + - Client pushes `DocUpdate`. |
| 102 | + - Server MUST respond with `Ack` (binary) in the HTTP response body. |
| 103 | + |
| 104 | +- For fragmented updates (`DocUpdateFragmentHeader` + `DocUpdateFragment`): |
| 105 | + - Client pushes the header and fragments. |
| 106 | + - Server MUST emit exactly one `Ack` per batch ID, referencing the batch ID. |
| 107 | + - It is RECOMMENDED that the `Ack` is returned as the HTTP response to the push that completes the batch |
| 108 | + (typically the final fragment). |
| 109 | + - Server MAY return an early non‑OK `Ack` when it can reject immediately (not joined, permission denied, rate limited, etc.). |
| 110 | + |
| 111 | +After accepting and applying a client update, the server broadcasts it to other sessions joined to the room via SSE: |
| 112 | + |
| 113 | +- Broadcast is typically `DocUpdate` with the same `batchId`, or the original fragments if fragmentation was used. |
| 114 | +- The sender does not need to receive its own update (implementation choice). |
| 115 | + |
| 116 | +## Client Handling of Server Frames (SSE) |
| 117 | + |
| 118 | +- Server-originated updates and backfills arrive on SSE as `event: msg` frames. |
| 119 | +- The client processes them exactly as it would process WebSocket binary frames. |
| 120 | + |
| 121 | +Ack directionality: |
| 122 | + |
| 123 | +- Clients SHOULD NOT send `Ack(status=0x00)` for server-originated updates (same reasoning as `protocol.md` WebSocket directionality). |
| 124 | +- Clients MAY send a non‑zero `Ack` via HTTP push if they fail to apply a server update (e.g., `invalid_update`, `fragment_timeout`). |
| 125 | + |
| 126 | +## Keepalive |
| 127 | + |
| 128 | +The `"ping"`/`"pong"` out-of-band keepalive in `protocol.md` is specific to WebSocket text frames. |
| 129 | + |
| 130 | +For SSE: |
| 131 | + |
| 132 | +- Implementations MAY send periodic SSE comments as heartbeats, e.g. `:keepalive\n\n`. |
| 133 | +- Heartbeats MUST NOT be parsed as protocol frames and MUST NOT be forwarded to rooms. |
| 134 | + |
| 135 | +## Ordering and Concurrency |
| 136 | + |
| 137 | +HTTP push requests can arrive concurrently, which can break assumptions about fragment ordering. |
| 138 | + |
| 139 | +Recommendations: |
| 140 | + |
| 141 | +- Serialize push handling per session key. |
| 142 | +- Enforce that `DocUpdateFragmentHeader` is observed before accepting fragments for that batch (or buffer until header arrives). |
| 143 | +- Use existing batch IDs as the correlation key for both fragments and `Ack`. |
| 144 | + |
| 145 | +## Loss Recovery on SSE Reconnect |
| 146 | + |
| 147 | +This profile assumes SSE can disconnect without replay. To recover: |
| 148 | + |
| 149 | +- Clients SHOULD treat an SSE reconnect as a connection reconnect. |
| 150 | +- Clients SHOULD re-issue `JoinRequest` for each active room with their current version so the server can backfill missing updates. |
| 151 | + |
| 152 | +## Compatibility |
| 153 | + |
| 154 | +- Works for all CRDT magic types defined in `protocol.md` (including `%ELO` from `protocol-e2ee.md`). |
| 155 | +- `%ELO` payload semantics remain unchanged; only the transport encoding differs. |
| 156 | + |
0 commit comments