Skip to content

Commit 4273a97

Browse files
os-billclaude
andauthored
docs(protocol): drop the five WebSocket message types WebSocketMessageType does not declare (#17700)
`WebSocketMessageType` is an exhaustive ten-member enum and `BaseWebSocketMessage` types every message's `type` against it, so a message whose type is outside the enum parses against no schema in the file. The realtime-protocol page taught five types that are not members: an in-band handshake (auth, auth_success, auth_error) and two acknowledgements (subscribed, unsubscribed). Measured on this tree. All ten declared members show at least one producer or consumer site under a message-type-context probe over packages/apps/examples; all five of the above return zero there, and zero in objectui. The contract agrees: IRealtimeService.handleUpgrade is optional and unimplemented, and WebSocketConfig.headers is the declared seam for a credential ("Custom headers for WebSocket handshake"). The five were documentation, not protocol. Prose only. The page now states the declared vocabulary, puts authentication on the upgrade request where the declared shapes put it, and shows the declared `ack` envelope for subscribe and unsubscribe acknowledgements. No schema, no enum and no behaviour changed; packages/spec/src/api/websocket.zod.ts is byte-unchanged. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
1 parent d34f9b6 commit 4273a97

1 file changed

Lines changed: 77 additions & 56 deletions

File tree

content/docs/protocol/kernel/realtime-protocol.mdx

Lines changed: 77 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,30 @@ names none, which is why every host the open framework ships answers `false`.
162162
A WebSocket upgrade endpoint is part of the planned transport (`IRealtimeService.handleUpgrade()`) and is not yet served — realtime stays out of the open framework (maintainer ruling, 2026-09-04). If a host ever mounts one, its realtime service names the mounted path via `getChannelRoute()` and discovery advertises `routes.realtime`, `handlerReady: true` and `capabilities.websockets` in the same step (see #2462, #14646).
163163
</Callout>
164164

165+
### Declared message vocabulary
166+
167+
**`WebSocketMessageType` (`packages/spec/src/api/websocket.zod.ts`) is a closed enum, and it is the
168+
only declaration this protocol has.** It names exactly ten message types:
169+
170+
`subscribe` · `unsubscribe` · `event` · `ping` · `pong` · `ack` · `error` · `presence` · `cursor` · `edit`
171+
172+
`BaseWebSocketMessage` types every message's `type` field as that enum, and each message schema pins
173+
`type` to one of those literals, so a message whose `type` is outside the enum parses against **no**
174+
schema in the file: `WebSocketMessageSchema` is a discriminated union over exactly those ten, and an
175+
unknown discriminant matches no branch at all.
176+
177+
Every message also carries the three `BaseWebSocketMessage` fields — `messageId` (UUID), `type`, and
178+
`timestamp` (ISO 8601).
179+
180+
<Callout type="warn">
181+
**Five message types this page used to teach are not in that enum:** an in-band handshake
182+
(`auth`, `auth_success`, `auth_error`) and two acknowledgements (`subscribed`, `unsubscribed`).
183+
Nothing in the runtime produces or consumes any of the five, and a client built on them sends and
184+
waits for messages the declared contract cannot carry. They were documentation, not protocol. The
185+
sections below describe what the enum does carry: see **Authentication** for where a credential
186+
goes instead, and `ack` for the acknowledgement the protocol actually declares.
187+
</Callout>
188+
165189
### Establishing Connection
166190

167191
**Client-side (JavaScript):**
@@ -170,12 +194,8 @@ const ws = new WebSocket('wss://api.acme.com/ws');
170194

171195
ws.onopen = () => {
172196
console.log('Connected to ObjectStack');
173-
174-
// Authenticate
175-
ws.send(JSON.stringify({
176-
type: 'auth',
177-
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
178-
}));
197+
// No in-band auth message: the credential travels on the upgrade request
198+
// (see Authentication). A socket that reaches `onopen` was already admitted.
179199
};
180200

181201
ws.onmessage = (event) => {
@@ -194,38 +214,37 @@ ws.onclose = (event) => {
194214

195215
### Authentication
196216

197-
Send authentication message immediately after connection:
217+
**The declared protocol has no authentication message.** A credential travels on the HTTP upgrade
218+
request, before the socket exists — `WebSocketConfig.headers` ("Custom headers for WebSocket
219+
handshake") is the declared seam for it, and `IRealtimeService.handleUpgrade(request)` receives that
220+
`Request` with the headers attached. So there is nothing to send once the socket is open, and no
221+
success or failure message to wait for: an upgrade that is refused never becomes a WebSocket, and the
222+
client sees the HTTP rejection or an immediate close rather than a message.
198223

199-
**Request:**
200224
```json
201225
{
202-
"type": "auth",
203-
"token": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
226+
"url": "wss://api.acme.com/ws",
227+
"headers": {
228+
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
229+
}
204230
}
205231
```
206232

207-
**Success Response:**
208-
```json
209-
{
210-
"type": "auth_success",
211-
"user_id": "user_123",
212-
"session_id": "session_abc",
213-
"expires_at": "2024-01-16T22:30:00Z"
214-
}
215-
```
233+
Browsers cannot set headers on `new WebSocket(...)`, so a browser client carries the credential the
234+
way the host's upgrade route accepts it — a cookie already scoped to the origin, or a short-lived
235+
ticket in the URL. Which of those a host accepts is that host's decision; `handleUpgrade` receives
236+
the whole `Request` either way.
216237

217-
**Failure Response:**
218-
```json
219-
{
220-
"type": "auth_error",
221-
"error": {
222-
"code": "INVALID_TOKEN",
223-
"message": "JWT token expired"
224-
}
225-
}
226-
```
238+
Failures that arise **after** admission are ordinary `type: "error"` messages (`ErrorMessageSchema`,
239+
flat `code` / `message` / `details`) — see **Token Expiration** for the one that matters most here.
227240

228-
**Connection closes on auth failure:** Server closes WebSocket if authentication fails within 10 seconds.
241+
<Callout type="warn">
242+
**Nothing serves any of this yet.** `handleUpgrade` is optional and unimplemented throughout the
243+
open framework (#2462, #3197), and per `IRealtimeService` the delivery path carries **no
244+
per-recipient authorization** at all — subscriptions carry no principal. Admission on the upgrade
245+
request is where the declared shapes put a credential; it is not a claim that a served endpoint
246+
checks one today.
247+
</Callout>
229248

230249
## Subscriptions
231250

@@ -253,12 +272,18 @@ Subscribe to changes on a specific object:
253272
- `filter`: Optional filter (same syntax as HTTP API filters)
254273

255274
**Success Response:**
275+
276+
The declared acknowledgement is an `ack` message (`AckMessageSchema`); there is no `subscribed` type.
277+
`ackMessageId` echoes the `messageId` of the message being acknowledged, `success` says whether it was
278+
accepted, and the optional `error` string carries the reason when it was not.
279+
256280
```json
257281
{
258-
"type": "subscribed",
259-
"subscription_id": "sub_1",
260-
"object": "task",
261-
"events": ["created", "updated", "deleted"]
282+
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
283+
"type": "ack",
284+
"timestamp": "2024-01-16T14:30:00Z",
285+
"ackMessageId": "550e8400-e29b-41d4-a716-446655440000",
286+
"success": true
262287
}
263288
```
264289

@@ -358,11 +383,16 @@ Stop receiving events for a subscription:
358383
}
359384
```
360385

361-
**Response:**
386+
**Response:** the same `ack` envelope as a subscribe acknowledgement — there is no `unsubscribed`
387+
type either.
388+
362389
```json
363390
{
364-
"type": "unsubscribed",
365-
"subscription_id": "sub_1"
391+
"messageId": "1f2b7d64-9a01-4c8e-9f3a-2b5c8d417e60",
392+
"type": "ack",
393+
"timestamp": "2024-01-16T14:35:00Z",
394+
"ackMessageId": "c0ffee00-1111-4222-8333-444455556666",
395+
"success": true
366396
}
367397
```
368398

@@ -565,9 +595,11 @@ Handle disconnections gracefully:
565595

566596
```javascript
567597
class ObjectStackClient {
568-
constructor(url, token) {
598+
// The credential is not held here: it travels on the upgrade request (see
599+
// Authentication), so reconnecting re-presents it the same way the first
600+
// connection did.
601+
constructor(url) {
569602
this.url = url;
570-
this.token = token;
571603
this.subscriptions = new Map();
572604
this.reconnectDelay = 1000; // Start with 1 second
573605
this.maxReconnectDelay = 30000; // Max 30 seconds
@@ -579,7 +611,6 @@ class ObjectStackClient {
579611
this.ws.onopen = () => {
580612
console.log('Connected');
581613
this.reconnectDelay = 1000; // Reset backoff
582-
this.authenticate();
583614
this.resubscribe();
584615
};
585616

@@ -593,10 +624,6 @@ class ObjectStackClient {
593624
};
594625
}
595626

596-
authenticate() {
597-
this.send({ type: 'auth', token: this.token });
598-
}
599-
600627
resubscribe() {
601628
this.subscriptions.forEach((config, id) => {
602629
this.send({ ...config, subscription_id: id });
@@ -616,7 +643,7 @@ class ObjectStackClient {
616643
}
617644

618645
// Usage
619-
const client = new ObjectStackClient('wss://api.acme.com/ws', token);
646+
const client = new ObjectStackClient('wss://api.acme.com/ws');
620647
client.connect();
621648
```
622649

@@ -1010,18 +1037,12 @@ ws.on('task.updated', (task) => {
10101037
## Security Considerations
10111038

10121039
### Authentication Required
1013-
All WebSocket connections must authenticate within 10 seconds:
10141040

1015-
```javascript
1016-
ws.onopen = () => {
1017-
ws.send(JSON.stringify({
1018-
type: 'auth',
1019-
token: getToken()
1020-
}));
1021-
};
1022-
1023-
// Server closes connection if no auth within 10 seconds
1024-
```
1041+
Every WebSocket connection is admitted on the **upgrade request**, before the socket exists — there
1042+
is no authentication message to send afterwards, because `WebSocketMessageType` declares none. A
1043+
connection that reaches `onopen` was already admitted; one that was refused never opened, and the
1044+
client sees the HTTP rejection or an immediate close. See **Authentication** for the declared seam
1045+
(`WebSocketConfig.headers` into `IRealtimeService.handleUpgrade`).
10251046

10261047
### Token Expiration
10271048
Handle JWT expiration gracefully:

0 commit comments

Comments
 (0)