(collaboration) add a get-ydoc endpoint on yhub

`GET /collaboration/get-ydoc/v1/docs/{id}` answers the current Yjs state of a
document as a raw binary update, the read counterpart of create-ydoc, and
204 when the document has no content yet
This commit is contained in:
Manuel Raynaud
2026-08-12 16:45:48 +02:00
parent d6c42a1d9d
commit 99e9b59e56
4 changed files with 71 additions and 0 deletions
+1
View File
@@ -87,6 +87,7 @@ and this project adheres to
`503` instead of denying access like a permission failure, so clients retry
instead of giving up. The built-in endpoints can also answer JSON on
`Accept: application/json`
- ✨(collaboration) add a get-ydoc endpoint on yhub
- ✨(backend) call YHubService to seed initial document content
- ✨(backend) reset the yhub connections of a document and its descendants
when an access or the link configuration changes
@@ -190,6 +190,19 @@ class YHubService:
return response
def get_ydoc(self, document):
"""
Return the current Yjs state of a document, None when it has none.
The raw update is what `create_ydoc` takes, so the state of a document
can be copied into another one. The built-in `ydoc` endpoint is not
used, it answers the lib0 encoding of an envelope rather than the
update itself.
"""
response = self.request("get", self.build_url("get-ydoc", document))
return response.content or None
def create_ydoc(self, document, update):
"""
Seed the initial Yjs state of a document.
@@ -222,3 +222,29 @@ def test_reset_connections_of_a_single_user(mock_request):
_args, kwargs = mock_request.call_args
assert kwargs["headers"]["X-User-Id"] == str(user.pk)
@patch("requests.request")
def test_get_ydoc(mock_request):
"""Should return the raw update the collaboration server holds."""
mock_request.return_value.ok = True
mock_request.return_value.content = b"\x01\x02raw yjs update"
update = YHubService().get_ydoc(DOCUMENT)
assert update == b"\x01\x02raw yjs update"
args, _kwargs = mock_request.call_args
assert args == (
"get",
f"http://yhub:3002/collaboration/get-ydoc/v1/docs/{DOCUMENT.id!s}",
)
@patch("requests.request")
def test_get_ydoc_without_content(mock_request):
"""A document the collaboration server holds no content for should return None."""
mock_request.return_value.ok = True
# yhub answers 204 No Content, hence an empty body
mock_request.return_value.content = b""
assert YHubService().get_ydoc(DOCUMENT) is None
+31
View File
@@ -340,6 +340,37 @@ const api = [
},
},
}),
// GET /collaboration/get-ydoc/v1/{org}/{docid} — the current state of a
// document as a RAW binary update (`Y.encodeStateAsUpdate` output), the read
// counterpart of create-ydoc: yhub's built-in `GET ydoc` answers a lib0-any
// encoded `{ doc, awareness }` envelope Django cannot decode. Answers 204
// when the room holds no content. Default access purpose: guarded like the
// built-in ydoc routes (read access on the doc — the admin JWT, or a user
// session able to retrieve it).
createApiEndpoint('get-ydoc', {
get: {
handler: async (req) => {
if (req.org !== ORG) {
return jsonResponse(400, { error: 'Unknown org' });
}
if (!UUID4.test(req.docid)) {
return jsonResponse(400, { error: 'Room name is invalid' });
}
const { gcDoc } = await req.yhub.getDoc(
req.room,
{ gc: true, nongc: false },
{ gcOnMerge: false },
);
// <= 3 bytes is yhub's "no effective content" convention (an empty
// update encodes to 2 bytes) — nothing to copy. `null` answers 204.
if (gcDoc == null || gcDoc.byteLength <= 3) {
return null;
}
// a Uint8Array is served as application/octet-stream, untouched
return gcDoc;
},
},
}),
// POST /collaboration/create-ydoc/v1/{org}/{docid} — create a document's
// initial Yjs state from a RAW binary update (`Y.encodeStateAsUpdate` /
// pycrdt `get_update()` output) posted as application/octet-stream. Unlike