Letterboard sync
For how to use this, see Interactive mode: with a letterboard.
A letterboard session keeps a tablet running Spelling Creator Board, the companion letterboard app, in step with the Practice page. The partner runs the lesson on a computer; the speller answers on the tablet. The step on the tablet follows the computer, and the sentence being spelled appears in the computer's answer box as it is spelled.
The contract both sides follow is docs/live-sync.md in the board's repository. The board side was built first, against a fake socket; this page is the hub side of that contract, which the board's own tests play the host against.
Roles
- Host: the Practice page in a browser, signed in to the hub. It owns the lesson and the step, shows a share code, and records the answers through the existing responses endpoint, since it is the signed-in side.
- Board: the tablet. It joins with the code only, no account, and follows. It may ask to move a step; the host decides and tells everyone where they are.
Is it available here?
Only the Cloudflare Worker can host a session, since the room is a Durable Object. Before interactive mode offers its letterboard button it asks the hub:
| Request | Answer |
|---|---|
GET /practice | 200 { "available": true }, or false on a Worker deployed without the PRACTICE_ROOM binding |
A self-hosted Node API doesn't register the route, so the request reaches the frontend fall-through and answers 404. The page treats that, a false and a network failure alike: no button. The answer is kept for the page load (checkPracticeAvailable in apps/web/src/lib/practiceLink.js), except a network failure, which is asked again the next time a walkthrough opens.
Transport
One WebSocket per participant at wss://<hub>/practice/<code>, served by the Worker next to /collab/<code> (handlePractice in apps/api/src/routes/practice.js). The host opens it with ?create=1&token=<jwt>, like a collaboration host; the board opens it with no query string. The Worker verifies the host's Supabase JWT, applies the join limits, and forwards the upgrade to the PracticeRoom Durable Object for that code with the role in an X-Practice-Role header. The token never reaches the room, and a client-supplied copy of the header is dropped. practiceRoom(env, code) is the one place a code becomes a room address, and it folds case again, so no path can split one session across two objects.
The token is in the query because a browser can't set a header on a WebSocket, the same trade /collab makes. It is a Supabase access token, which expires within the hour, and it travels over TLS to Cloudflare's edge, which terminates the connection itself. Anyone running a proxy in front of a hub should keep query strings out of its access logs for /practice and /collab.
Messages are JSON text frames, one object each, with a type. Every other field is optional, so either side can add fields later without breaking the other, and an unknown type is ignored on both sides. Anything the room relays is rebuilt from the fields listed below rather than forwarded as received, so a participant can't pass extra fields to the other side.
The room keeps the sockets alive with Cloudflare's auto-response: the board sends a WebSocket ping every 25 seconds, which the runtime answers itself, and the host (a browser, which can't send a protocol ping from JavaScript) sends a text ping that is answered with pong without waking the object, exactly as the collaboration client does.
Codes
A code matches the collaboration rule [A-Za-z0-9_-]{1,128} (SESSION_CODE_RE, exported from routes/collab.js and imported here, so the two can't drift), and the Worker folds it to capitals before addressing the room, so a partner typing a code on a tablet doesn't have to hold shift. The Practice page issues six characters from an alphabet with no letters that look like digits and no digits that look like letters (newPracticeCode in apps/web/src/lib/practiceLink.js), about 887 million codes. The code is the only secret: the room refuses a second host rather than letting whoever holds the code take a session over, and boards' joins are limited by address (below).
Messages
Board to host (relayed to the host only):
| Type | Fields | When |
|---|---|---|
join | client: "board" | Right after the socket opens. The room has already answered it: hello goes out when the socket is accepted. |
text | text | Every time the sentence on the board changes, including when it clears. Interactive mode puts it in the answer box. |
answer | text | The partner tapped Done on a question. Interactive mode records it as the answer to the current step and moves on. |
step | step | The partner tapped Previous or Next on the board. Interactive mode moves if it agrees; the state that follows says where. |
Host to board:
| Type | Fields | When |
|---|---|---|
hello | lessonId?, step?, text? | When the board's socket is accepted, with the host's last state. No lessonId means no lesson is open yet. |
state | lessonId?, step?, text? | Whenever the lesson, the step or the answer box changes, including after every answer or step from a board. |
removed | message? | The host ended the session (its socket closed). The board's socket is then closed with code 1000. |
error | message? | The room refused the join: no session with that code, or the session is full. The socket is then closed with code 1008. |
The room sends the host two messages of its own, which are not part of the board contract and never reach a board: hello with boards, the number of boards listening when the host connected, and boards with count every time one joins or leaves. The Practice page shows the count beside the code.
step is the index, from 0, in the walkthrough buildInteractiveSteps produces (see Interactive mode): a section's material as one step, then each question as its own. The board computes the same walkthrough from the same document, so an index means the same screen on both. While the summary is showing, the host reports the last step's index.
text in a host message is the answer box's content, which is empty on a content step. The board takes it only onto an empty board (a board that just joined mid-answer); otherwise the board's own words are the source, and text from the board replaces the answer box rather than adding to it.
What the room validates
| Field | Rule |
|---|---|
lessonId | A non-empty string of at most 100 characters; anything else is left out of the stored state. |
step | A non-negative integer up to 100,000; anything else is dropped (from a board) or left out (from the host). |
text | A string, cut to MAX_RESPONSE_LENGTH (5,000 characters, the same cap as a saved answer); anything else dropped. |
| A frame | Text only, at most 32 KB, valid JSON with a string type. |
A state keeps only the fields the host sent and the room accepted, so a host that omits text doesn't clear a board's sentence, and a late board's hello carries exactly what the host last said.
Lifetime
- One host. A second
create=1on a live code is refused with409. When a host connects to a code a previous session used, the stored state is cleared first, so nothing of the old session reaches the new one's boards. - The room dies with its host. When the host's socket closes, every board is sent
removedand closed, and the state is deleted. A board that joins the code afterwards is told there is no session. The lesson stays open on the board, so the speller can carry on alone. - A board leaving only changes the host's
boardscount. - A socket on its way out is retired at once. A refused board, the boards of a host who has left, and that host are marked (
retire) the moment the room is done with them, and nothing counts or addresses a marked socket. A socket stays in the room's list until its close handshake finishes, and without the mark a board from an ended session could be counted for, or sent the state of, a new host who took the code over in that window. - The last state is in storage, not memory, so a board that joins after the object has been evicted while its sockets hibernated is still caught up. It is a row in the object's SQLite database, written synchronously, so storing it doesn't put an await in front of the broadcast on every keystroke. The output gate still holds the broadcast until the write is durable.
Limits
| Limit | Value | Where |
|---|---|---|
| Sessions started, per user | 5 per minute (token bucket) | takeJoinToken in lib/joinLimit.js, keyed practice-rl:user:<uid> by routes/practice.js |
| Board joins, per address | 10 per minute (token bucket) | the same, keyed practice-rl:ip:<ip> from the platform seam's clientIp |
| Boards per session | 10 | MAX_BOARDS in practice-room.js |
| Messages per connection | 30 text or state, 5 answer and 5 step per second | RATE in practice-room.js |
| Anything else, per connection | 10 per second, shared | RATE.other: join, unknown types and frames that aren't JSON |
| One frame | 32 KB | MAX_MESSAGE_BYTES |
Over-budget messages are dropped, and a connection that exceeds its budget 100 times (MAX_VIOLATIONS) is closed as abusive. Boards are anonymous, which is why their joins are limited by address where a collaboration join is limited by user; the limit is what keeps guessing a code from being cheap. A board join with no address is refused with 400 rather than put in a bucket shared with every other such join, where one client could spend everyone's. Cloudflare's edge always sets the address, so in practice this only turns away a request that bypassed it.
A stored bucket that doesn't parse is treated as no bucket, so a corrupt row starts a fresh one instead of failing every join until it expires.
Refusals the Worker makes before the upgrade (an invalid code, a host who isn't signed in, a rate limit, a missing binding) are HTTP statuses (400, 401, 429, 500), which a board sees as a connection that failed. Refusals the room makes (no session, full) are an error message over the socket it opened, which the board can show.
A browser isn't told the status of a refused upgrade either, so the Practice page can't say which of those it was. It reports a socket that closed before hello as refused ("The hub turned the session down"), suggesting a minute's wait for the commonest cause, and one that dropped after hello as disconnected.
The host in the browser
usePracticeHost (apps/web/src/lib/practiceLink.js) owns the socket. It takes the lesson id, the step index and the answer box's text as props, and sends a state whenever any of them changes, coalesced over 50 ms so a burst of keystrokes is one message. The first state goes out on hello, so a board that was waiting on the code is caught up at once; from then on the room's stored copy of the last state does that for later boards. What the board sends comes back as three callbacks, onText, onAnswer and onStep, and the hook reports status (idle, connecting, live), the code, the number of boards and an error key the component translates.
InteractiveLesson wires those callbacks to its own state:
onTextreplaces the current question's answer. On a content step there is no answer box, so it is ignored.onAnswerrecords the text as the current question's answer and moves on, as Next does; on the last step that finishes the lesson, with the responses rebuilt from the answers as they will be rather than from the render that hasn't happened yet (finish(answersNow)). On a content step, Done just moves on.onStepmoves to the step named if it exists, stopping at the last step for anything past it, and from the summary goes back into the lesson as Back does. Nothing is echoed: thestatethe hook sends on the resulting change is the answer.
The session ends with the walkthrough: closing interactive mode stops the hook, and so does unmounting it. The letterboard button and its popover are LetterboardLink.jsx, rendered only once the hub has said it can host one (usePracticeAvailable, above), and the start button only for a signed-in user, since the host files the answers. The WebSocket base comes from apiWsUrl() in @spelling-creator/core/config, the same helper live collaboration uses.
Deploying
The room needs the PRACTICE_ROOM binding and the v3 migration in apps/api/wrangler.jsonc, both of which ship with the code. Wrangler applies the migration on the next wrangler deploy (pnpm deploy), with nothing to run by hand. Three things follow from it being a Durable Object migration:
- Use
wrangler deploy. A gradual rollout made withwrangler versions uploadcan't apply a Durable Object migration. - Leave an applied migration alone. Cloudflare records which tags have run, so editing or removing
v3later makes the next deploy fail. A later change to the classes is a new tag. - Roll forward, not back. A rollback to a version from before the migration isn't offered, so undoing this release means deploying a fix.
Where the code lives
| File | What it does |
|---|---|
apps/api/src/practice-room.js | The PracticeRoom Durable Object: roles, the stored state, relay, validation, limits |
apps/api/src/routes/practice.js | handlePractice, handlePracticeInfo, parsePracticeCode and practiceRoom: gate, limits, forwarding |
apps/api/src/lib/joinLimit.js | The join token bucket, shared with /collab |
apps/web/src/lib/practiceLink.js | usePracticeHost, usePracticeAvailable and newPracticeCode |
apps/web/src/components/LetterboardLink.jsx | The tablet button and popover in interactive mode's top bar |
apps/web/src/components/InteractiveLesson.jsx | Where the board's text, answers and moves are applied |
apps/stub-api/src/practice.js | The same room on the local stub API, so a session can be tried without Cloudflare |