Skip to content

feat(webapp): let the hub hold the document - #241

Merged
ssowonny merged 4 commits into
mainfrom
feat/hub-holds-the-document
Sep 20, 2026
Merged

ssowonny merged 4 commits into
mainfrom
feat/hub-holds-the-document

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

  • The co-editing relay never parses a frame, so nobody owns the document — and every property that needs an owner is faked somewhere else.
  • The hub now holds it: reearth/ygo v1.50.0, pure Go, no cgo, mounted behind the same proj() wrapper as every other per-project route.
  • Beside the relay, not instead of it, for one release.
  • Wire compatibility with the browser's yjs is now a CI test, checked in as bytes so CI needs no node.

What "nobody owns it" costs today

compensation exists only because
seed claim + 10s grace timer two clients seeding one file build two documents that duplicate every character on merge
maxRoomBytes (8 MiB) + rebuild an append-only log grows without bound; a document does not
"whoever stops typing last writes the file" there is no server-side snapshot, so N co-editors write N versions of identical text
conflict copies on every network blip a client that loses the relay edits its own buffer, and two browsers overwrite each other

That last one cost a user six characters (#234). The conflict copy is the right safety net; needing one every time a laptop changes network is not.

The decision this rests on

Until now the hub never parsed a client-supplied CRDT updatecollab.go stores opaque bytes and hands them on. That ends here, deliberately, recorded in the PRD with the conditions it carried: behind proj(), bounded inputs, old relay reachable for a release.

The constraint that originally made this a relay — "a cgo y-crdt would break the cross-compiled release the same way a cgo sqlite would" — no longer holds.

Three things the handler gets right

  • The room name is the hub's. ygo reads it from PathValue("room") or the URL's last segment. A caller who could name the room would make the project id in the path decoration — any member of any project could join any other project's document by asking for its name. Verified failing without the guard: room = "../some-other-project/secrets.md".
  • PermRead, not PermWrite. A read-only member may open a file and watch it being edited; the connection is marked read-only and their writes are dropped server-side. That's what read-only means everywhere else here — the relay's PermWrite was the coarser answer.
  • Hidden paths are 404, never 403, because a 403 confirms the file is there.

Wire fixtures, in CI

Produced by the exact yjs build the frontend ships, checked in as bytes — CI runs Go without node, and a test that needs a toolchain it doesn't have is a test that gets skipped.

V1 V2
Go reads a yjs document
Go reads a yjs document of 10,000 ops
Go's own output round-trips

Memory, measured

~100 KB per actively-edited document — every keystroke is its own item until GC merges them, so editing costs ~10× the content. A hub with 100 open documents is ~10 MB, two orders of magnitude under the 8 MiB-per-room log cap this replaces. The limit worth having is therefore eviction, not bytes.

Verification

Go: ok, 412s, 0 failures, vet clean. Stages 0 and 1 of docs/collab-provider-prd.md.

🤖 Generated with Claude Code

ssowonny and others added 4 commits September 19, 2026 23:41
collab.go is a relay: it never parses a frame, so nobody owns the document,
and every property that needs an owner is faked somewhere else. A seed CLAIM
with a grace timer, because two clients seeding one file build two documents
that duplicate every character on merge. A byte cap on a log that only grows,
and a rebuild-from-scratch when it is hit. A client-side snapshot rule —
"whoever stops typing last writes the file" — that makes N co-editors write N
versions of identical text.

And a failure mode with teeth: a client that loses the relay edits its own
buffer, so two browsers hold two documents and overwrite each other. That cost
a user six characters (#234), which is why upload/content now takes If-Match
and parks the loser as a conflict copy. The copy is the right safety net;
needing one every time a laptop changes network is not.

So the hub holds it. github.com/reearth/ygo v1.50.0: pure Go, no cgo — which
is the constraint that made this a relay in the first place and the one that
has since changed — mounted as an http.Handler behind the same proj() wrapper
every other per-project route uses.

The decision this rests on is recorded in the PRD and is not mine: until now
the hub never parsed a client-supplied CRDT update. It does now, deliberately,
with the conditions that came with it.

Three things the handler gets right, each with a test:

  - THE ROOM NAME IS THE HUB'S. ygo reads it from PathValue("room") or the
    URL's last segment, so a caller who could name the room would make the
    project id in the path decoration — any member of any project could join
    any other project's document by asking for its name. The handler derives
    (project, path) itself, after proj() has resolved the project. Verified
    failing without it.
  - PermRead, not PermWrite. A read-only member may OPEN a file and watch it
    being edited; their connection is marked read-only and their writes are
    dropped server-side, which is what read-only means everywhere else here.
  - A path the caller cannot see is 404, never 403, because a 403 confirms
    the file is there.

Beside the relay rather than instead of it, for one release.

The wire fixtures are now a CI test. They were produced by the exact yjs build
the frontend ships and are checked in as BYTES, because CI runs Go without
node and a test that needs a toolchain it does not have is a test that gets
skipped. Both directions, both encodings, including a 10,000-op document. A
version bump that breaks the wire fails the build instead of the editor.

Stage 0 and Stage 1 of docs/collab-provider-prd.md. Memory measured at ~100 KB
per actively-edited document (every keystroke is its own item until GC merges
them), two orders of magnitude under the 8 MiB log cap it replaces — so the
limit worth having is eviction, not bytes.

Go: ok, 412s, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seed CLAIM existed because a relay cannot seed: it never parses a frame,
so it has no way to turn a file into a document. Exactly one joiner was
therefore told "you are first, build it from the bytes you loaded" — with a
grace timer, because a claim that never produced anything left every later
joiner holding a blank document that the source editor would then cheerfully
save over the file.

A hub that holds the document can just do it. LoadDoc runs once, when the room
is created and before any client is attached, so there is nothing to claim and
nothing to race: the document starts as the file, deterministically, every
time. A file that does not exist yet starts empty, which is an ordinary thing
to open an editor on and never an error.

Bounded by maxSeedBytes, because editing costs roughly ten times the content
in CRDT items — a memory ceiling rather than a file-size opinion.

StoreUpdate is left returning nil deliberately, and says so: snapshotting the
document back to the file is Stage 3, and until then the client still saves
through upload/content exactly as it does now.

Completes Stage 1 of docs/collab-provider-prd.md, including the "rebuilt from
the file deterministically" criterion that the previous commit claimed the
stage without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every per-project route the hub serves has to be classified in desktopRoutes,
because one the desktop app does not know is answered from LOCAL STATE — a
plausible wrong answer rather than an error, which the comment in that file
says has shipped twice.

/ycollab has a sharper consequence than the relay it sits beside: the document
IS hub state now, so a desktop answering locally would hand the editor a
second, private document, and the first thing that document does is get saved
over the file.

streaming() learns about websockets while here. It decides which client serves
a request BEFORE the answer exists, by asking whether the caller wants a
long-lived connection — an upgrade is exactly that, even though it is not a
stream of frames the proxy can read.

Caught by CI, not locally, because I ran ./internal/webapp and CI runs ./... —
the test that fails lives in cmd/bdrive. Full module now: 12 packages ok.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…over

A websocket handshake ends with the handler HIJACKING the connection. The
gzip wrapper added in #232 implements Flush and Unwrap but not http.Hijacker,
so the upgrade could not happen and the client got a 500 it had no way to
explain.

Invisible until now because nothing in this hub upgraded anything. It became
visible the moment a browser tried to reach the hub-held document — and only
from a browser: Go's websocket dialer sends no Accept-Encoding, so the
middleware never wrapped it and the Go test passed while every real client
failed. The test now dials the way a browser does, and fails without the fix
with exactly the 500 that was observed.

Skipped rather than made hijackable. There is nothing to gzip on a handshake,
and a Hijack method on a compressing writer is a trapdoor that returns a
socket somebody may already have written a gzip header to.

This is the second capability that wrapper has had to learn to forward — Flush
was the first (#234, the unflushable-writer hang). Wrapping a ResponseWriter
means answering for every interface the real one implements, and getting it
wrong reads as an unexplainable 500 or a hang rather than a compile error.

Also here, because the route is not reachable without them: the {room...}
variant y-websocket's URL produces, and both ycollab routes classified for the
desktop app — an unclassified route is answered from LOCAL state, which for a
document the hub now owns would hand the editor a second, private copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ssowonny
ssowonny merged commit 67e88d0 into main Sep 20, 2026
3 checks passed
@ssowonny
ssowonny deleted the feat/hub-holds-the-document branch September 20, 2026 07:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant