@origonai/web-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -0
- package/dist/audio/audio-processor.js +1 -0
- package/dist/audio/wasm-gen/origon_web_audio_bg.wasm +0 -0
- package/dist/index.d.ts +472 -0
- package/dist/origon-web-sdk.js +5465 -0
- package/dist/origon-web-sdk.js.map +1 -0
- package/docs/backend-integration.md +485 -0
- package/docs/contract.md +71 -0
- package/docs/new-chat-protocol.md +91 -0
- package/docs/voice.md +165 -0
- package/package.json +63 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
# MoQ-Switch — WebTransport Client Integration Guide
|
|
2
|
+
|
|
3
|
+
For frontend engineers building a **browser** (or any WebTransport-capable
|
|
4
|
+
runtime) client that joins a media-server call.
|
|
5
|
+
|
|
6
|
+
> **Read [`client-integration.md`](client-integration.md) first** for the
|
|
7
|
+
> wire-level reference (control messages, audio framing, error taxonomy,
|
|
8
|
+
> reconnect tiers). This doc only covers what is **WebTransport-specific**.
|
|
9
|
+
> The protocol on top of WT is byte-for-byte identical to the native QUIC
|
|
10
|
+
> path.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. What WebTransport buys you
|
|
15
|
+
|
|
16
|
+
WebTransport is the browser's standardized API for QUIC-shaped streams +
|
|
17
|
+
datagrams over HTTP/3. The browser can't open a raw QUIC connection; it
|
|
18
|
+
*can* open a WebTransport session, which gives you:
|
|
19
|
+
|
|
20
|
+
- Bidirectional sub-streams (≈ QUIC bidi streams)
|
|
21
|
+
- Unidirectional sub-streams (≈ QUIC uni streams)
|
|
22
|
+
- Datagrams (≈ QUIC datagrams)
|
|
23
|
+
- Connection lifetime + close events
|
|
24
|
+
|
|
25
|
+
The media-server treats a WebTransport session as a 1:1 substitute for
|
|
26
|
+
a raw QUIC connection from the native client. **Same Connect handshake,
|
|
27
|
+
same ControlMessages, same audio datagrams.**
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. Transport setup
|
|
32
|
+
|
|
33
|
+
| Item | Value |
|
|
34
|
+
|---|---|
|
|
35
|
+
| URL | `https://<media-server-host>:<port>/web` |
|
|
36
|
+
| ALPN | `h3` (negotiated automatically by the browser) |
|
|
37
|
+
| Protocol header (sent automatically) | `:protocol = webtransport` |
|
|
38
|
+
| TLS | 1.3 mandatory; WebPKI-trusted cert in prod, see §10 for dev |
|
|
39
|
+
| Datagrams | required (server requires; browser supports natively) |
|
|
40
|
+
| Path | `/web` (default; configurable on the server via `[server] wt_path`) |
|
|
41
|
+
|
|
42
|
+
### 2.1 Opening the session
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const wt = new WebTransport('https://media.example.com/web', {
|
|
46
|
+
// Production: omit. Browser uses the WebPKI trust store.
|
|
47
|
+
// Development with a self-signed cert, see §10:
|
|
48
|
+
// serverCertificateHashes: [{ algorithm: 'sha-256', value: <ArrayBuffer> }],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
await wt.ready // throws if the handshake failed
|
|
52
|
+
console.log('connected; closed=', wt.closed)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`wt.closed` is a Promise that resolves (or rejects) when the session
|
|
56
|
+
ends. Treat it as your "session lost" signal.
|
|
57
|
+
|
|
58
|
+
There is **no explicit ALPN knob** in the WebTransport API. The browser
|
|
59
|
+
negotiates `h3` for you; the server's dual-ALPN listener accepts it.
|
|
60
|
+
|
|
61
|
+
### 2.2 Setting up the audio path
|
|
62
|
+
|
|
63
|
+
The browser cannot use the OS WebRTC pipeline through WebTransport
|
|
64
|
+
directly; you control the encoder yourself.
|
|
65
|
+
|
|
66
|
+
| Concern | Recommended primitive |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Mic capture | `getUserMedia({ audio: true })` → `MediaStreamTrackProcessor` |
|
|
69
|
+
| Opus encode | `AudioEncoder` from WebCodecs API (`codec: 'opus'`) |
|
|
70
|
+
| Opus decode (per peer) | `AudioDecoder` (`codec: 'opus'`) per `PeerAttached.alias` |
|
|
71
|
+
| Playback | Each decoder's PCM into an `AudioWorkletNode` mixer → `AudioContext.destination` |
|
|
72
|
+
| Frame sizing | Encoder configured for 20 ms (960 samples @ 48 kHz mono) |
|
|
73
|
+
|
|
74
|
+
WebCodecs is Chromium-first today (Safari TP, Firefox behind flag).
|
|
75
|
+
Document a min-supported-browser policy with the product team.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 3. The wire on top of WT — what maps to what
|
|
80
|
+
|
|
81
|
+
| Native client (QUIC) | Browser client (WT) | Carries |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| Bidi QUIC stream | `wt.createBidirectionalStream()` (client-opened) | ControlMessages — Connect, NegotiateMedia, Hold, Mute, SendDtmf, Resume + their `*Ok` + server-push |
|
|
84
|
+
| QUIC datagrams | `wt.datagrams` (readable + writable streams) | Audio frames (one frame per datagram), uplink + downlink |
|
|
85
|
+
| QUIC uni stream (server→client) | `wt.incomingUnidirectionalStreams` | DTMF events when the peer pressed a digit |
|
|
86
|
+
| QUIC graceful close | `wt.close({ closeCode, reason })` | Hangup |
|
|
87
|
+
|
|
88
|
+
Everything in [`client-integration.md`](client-integration.md) §4–§12 applies
|
|
89
|
+
unchanged. Substitute "QUIC bidi stream" with "WT bidi sub-stream" and
|
|
90
|
+
"QUIC datagram" with "WT datagram." That's it.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 4. The control stream (bidi sub-stream)
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
// Open the control stream — must be the first thing you do.
|
|
98
|
+
const ctrl = await wt.createBidirectionalStream()
|
|
99
|
+
const writer = ctrl.writable.getWriter()
|
|
100
|
+
const reader = ctrl.readable.getReader()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 4.1 Framing
|
|
104
|
+
|
|
105
|
+
Every ControlMessage on the bidi stream is:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
+------------------+------------------+-----------------+
|
|
109
|
+
| msg_type (vi) | payload_len (vi) | payload (bytes) |
|
|
110
|
+
+------------------+------------------+-----------------+
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`(vi)` = QUIC varint (1/2/4/8 bytes, big-endian, RFC 9000 §16).
|
|
114
|
+
|
|
115
|
+
Cap: **64 KiB per framed message.** Server closes with `ProtocolViolation`
|
|
116
|
+
if you exceed.
|
|
117
|
+
|
|
118
|
+
> **Ground truth.** Use [`moq-core/codec/src/wire/control.rs`](../../moq-core/codec/src/wire/control.rs)
|
|
119
|
+
> as the authoritative encoder/decoder source. Field ordering, varint
|
|
120
|
+
> widths, and KVP layout are precise; do not roll your own.
|
|
121
|
+
|
|
122
|
+
### 4.2 Connect — first frame, always
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
// Pseudo-typescript; encoder is your moq-codec port.
|
|
126
|
+
const connect = encodeControlMessage('Connect', {
|
|
127
|
+
request_id: 1n,
|
|
128
|
+
call_token: jwtBytes,
|
|
129
|
+
sdk_name: 'web-sdk',
|
|
130
|
+
sdk_version: '1.0.0',
|
|
131
|
+
offers: [{
|
|
132
|
+
class: 'Audio',
|
|
133
|
+
purpose: 'Primary',
|
|
134
|
+
codecs: ['Opus', 'PCMU', 'PCMA'], // server picks Opus
|
|
135
|
+
}],
|
|
136
|
+
frame_size_ms: 20,
|
|
137
|
+
})
|
|
138
|
+
await writer.write(connect)
|
|
139
|
+
|
|
140
|
+
const connectOk = await readNextControlMessage(reader)
|
|
141
|
+
// → { type: 'ConnectOk', request_id: 1n, server_epoch_unix, bindings: [{ alias, codec, … }], rejected: [] }
|
|
142
|
+
|
|
143
|
+
const audioAlias = connectOk.bindings[0].alias
|
|
144
|
+
const serverEpochUnix = connectOk.server_epoch_unix
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Server-side gates that will close the session if violated:
|
|
148
|
+
- First frame on this bidi must be `Connect`. Anything else → close.
|
|
149
|
+
- A datagram or uni stream that arrives before `ConnectOk` is rejected.
|
|
150
|
+
|
|
151
|
+
### 4.3 Mid-call verbs + server-push
|
|
152
|
+
|
|
153
|
+
After `ConnectOk` the bidi multiplexes:
|
|
154
|
+
- Your verbs out (`Hold`, `Mute`, `SendDtmf`, `Resume`, `NegotiateMedia`).
|
|
155
|
+
- Server `*Ok` responses (correlate by `request_id`).
|
|
156
|
+
- Server-push events (`PeerAttached`, `PeerDetached`, `EndpointHeld`,
|
|
157
|
+
`EndpointMuted`, `PeerStateChanged`, `DtmfReceived`, `EndpointHangup`,
|
|
158
|
+
`MoqError`) — these carry `request_id = 0`.
|
|
159
|
+
|
|
160
|
+
A single read loop on `ctrl.readable.getReader()` is sufficient. Dispatch
|
|
161
|
+
by `msg_type`.
|
|
162
|
+
|
|
163
|
+
### 4.4 Bytes may arrive split
|
|
164
|
+
|
|
165
|
+
WT streams give you `Uint8Array` chunks at arbitrary boundaries. Buffer
|
|
166
|
+
until you can decode at least the `msg_type | payload_len` prefix, then
|
|
167
|
+
the full payload, then dispatch. Treat the reader as a byte stream, not
|
|
168
|
+
a message stream.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 5. Audio datagrams
|
|
173
|
+
|
|
174
|
+
### 5.1 Uplink (you → server)
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
const dgWriter = wt.datagrams.writable.getWriter()
|
|
178
|
+
|
|
179
|
+
// Per encoded Opus frame from your AudioEncoder:
|
|
180
|
+
async function sendFrame(encodedBytes, objectIdInGroup) {
|
|
181
|
+
const groupId = BigInt(Math.floor(Date.now() / 1000)) - BigInt(serverEpochUnix)
|
|
182
|
+
const datagram = encodeObjectDatagram({
|
|
183
|
+
track_alias: BigInt(audioAlias),
|
|
184
|
+
group_id: groupId,
|
|
185
|
+
object_id: BigInt(objectIdInGroup), // 0..49 within the wall-second
|
|
186
|
+
publisher_priority: 0,
|
|
187
|
+
payload: encodedBytes,
|
|
188
|
+
end_of_group: objectIdInGroup === 49,
|
|
189
|
+
})
|
|
190
|
+
await dgWriter.write(datagram)
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`group_id` is `(now_seconds - server_epoch_unix)`. `object_id` is the
|
|
195
|
+
position within the wall-second (0..49 at 20 ms cadence). Receivers use
|
|
196
|
+
the pair `(group_id, object_id)` for jitter buffering and gap detection.
|
|
197
|
+
|
|
198
|
+
`wt.datagrams.maxDatagramSize` gives the usable payload budget. Opus
|
|
199
|
+
40 B and PCMU/PCMA 160 B at 20 ms both fit any realistic MTU.
|
|
200
|
+
|
|
201
|
+
> See [`moq-core/codec/src/wire/data_stream.rs`](../../moq-core/codec/src/wire/data_stream.rs)
|
|
202
|
+
> for `ObjectDatagram` byte layout.
|
|
203
|
+
|
|
204
|
+
### 5.2 Downlink (server → you)
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
const dgReader = wt.datagrams.readable.getReader()
|
|
208
|
+
const decoders = new Map() // alias → AudioDecoder
|
|
209
|
+
|
|
210
|
+
while (true) {
|
|
211
|
+
const { value, done } = await dgReader.read()
|
|
212
|
+
if (done) break
|
|
213
|
+
const obj = decodeObjectDatagram(value)
|
|
214
|
+
const dec = decoders.get(obj.track_alias)
|
|
215
|
+
if (!dec) continue // alias not yet PeerAttached
|
|
216
|
+
dec.decode(new EncodedAudioChunk({
|
|
217
|
+
type: 'key',
|
|
218
|
+
timestamp: derivedFromGroupAndObject(obj.group_id, obj.object_id),
|
|
219
|
+
data: obj.payload,
|
|
220
|
+
}))
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
One decoder per `PeerAttached.alias`. On `PeerDetached`, close the
|
|
225
|
+
decoder and drop the entry.
|
|
226
|
+
|
|
227
|
+
### 5.3 Loss handling
|
|
228
|
+
|
|
229
|
+
Datagrams are unreliable — gaps happen. Detect via `object_id` gap,
|
|
230
|
+
ask `AudioDecoder` for a concealment frame (`AudioDecoder.flush()` or
|
|
231
|
+
manual PLC). Server never retransmits.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## 6. DTMF inbound (unidirectional sub-stream)
|
|
236
|
+
|
|
237
|
+
DTMF *from a peer* arrives on a fresh server-initiated uni stream, one
|
|
238
|
+
per digit:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
const uniReader = wt.incomingUnidirectionalStreams.getReader()
|
|
242
|
+
while (true) {
|
|
243
|
+
const { value: stream, done } = await uniReader.read()
|
|
244
|
+
if (done) break
|
|
245
|
+
// stream is a ReadableStream<Uint8Array>
|
|
246
|
+
handleDtmfUniStream(stream) // decodes SubgroupHeader + DtmfEvent + EndOfGroup
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The framing on this stream is documented in
|
|
251
|
+
[`docs/wire-protocol.md`](wire-protocol.md) §"Stream paths (non-audio)".
|
|
252
|
+
The payload is a single `DtmfEvent { digit, duration_ms }` protobuf
|
|
253
|
+
message.
|
|
254
|
+
|
|
255
|
+
DTMF *to the server* (you pressing a digit) is the `SendDtmf` verb on
|
|
256
|
+
the bidi control stream — not a uni stream. The browser cannot open
|
|
257
|
+
WT uni sub-streams at all in some specs/browsers, so we never required
|
|
258
|
+
it for the outbound direction.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 7. Session control verbs
|
|
263
|
+
|
|
264
|
+
Same shape as native. See [`client-integration.md`](client-integration.md)
|
|
265
|
+
§7 for full details. Quick reminder:
|
|
266
|
+
|
|
267
|
+
| Verb | Direction | Cap claim required |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `Hold { held: bool }` | Client → Server | `session.hold` |
|
|
270
|
+
| `Mute { scope: Uplink \| Downlink \| Both \| None }` | Client → Server | `session.mute_self` |
|
|
271
|
+
| `SendDtmf { digit, duration_ms }` | Client → Server | `dtmf_send` |
|
|
272
|
+
| `Resume { call_token, last_event_seq }` | Client → Server | (token only) |
|
|
273
|
+
| `NegotiateMedia { offers, frame_size_ms }` | Client → Server | (token only; additive) |
|
|
274
|
+
|
|
275
|
+
The corresponding `*Ok` responses carry the echoed `request_id`. Server
|
|
276
|
+
pushes the matching state-change event (`EndpointHeld`, `EndpointMuted`,
|
|
277
|
+
`PeerStateChanged`, `DtmfReceived`) for other peers' UI consistency.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 8. Hangup
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
wt.close({ closeCode: 0, reason: '' })
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Same semantics as the native client's QUIC `APPLICATION_CLOSE`:
|
|
288
|
+
|
|
289
|
+
| Reason | closeCode |
|
|
290
|
+
|---|---|
|
|
291
|
+
| Normal | `0x00` |
|
|
292
|
+
| User cancel | `0x01` |
|
|
293
|
+
| App error | `0x02` |
|
|
294
|
+
|
|
295
|
+
The server treats the WT close as a graceful hangup (ADR-0015), tears
|
|
296
|
+
down the bridge, and emits `EndpointHangup{origin: Client}` to remaining
|
|
297
|
+
peers.
|
|
298
|
+
|
|
299
|
+
`wt.closed` Promise resolves with the close info when the server (or
|
|
300
|
+
the network) closes from its side.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## 9. Reconnect — what's different from the native path
|
|
305
|
+
|
|
306
|
+
| Native (QUIC) | Browser (WT) |
|
|
307
|
+
|---|---|
|
|
308
|
+
| Tier 1 — connection migration | **Not available.** Browser does not expose QUIC migration. |
|
|
309
|
+
| Tier 2 — 0-RTT resumption | **Not available.** WebTransport API does not expose 0-RTT. |
|
|
310
|
+
| Tier 3 — `Resume` verb | **Required path.** Open a fresh WT session, present same JWT (still within `exp`), send `Resume { call_token, last_event_seq }` on the new bidi. |
|
|
311
|
+
|
|
312
|
+
So in the browser, *all* reconnects go through Tier 3. The server holds
|
|
313
|
+
the endpoint suspended for `reconnect_window` (default 30 s) after the
|
|
314
|
+
session drops. Reconnect within that window with the same `eid` (in the
|
|
315
|
+
token) and the bridge state is preserved — `ResumeOk` returns the
|
|
316
|
+
`EndpointSnapshot` (mute, held, peer subscriptions) and you resume
|
|
317
|
+
audio pumps against the same aliases.
|
|
318
|
+
|
|
319
|
+
```js
|
|
320
|
+
wt.closed.catch(async () => {
|
|
321
|
+
// optional: visual "reconnecting…" state
|
|
322
|
+
for (const delay of [100, 250, 500, 1000]) {
|
|
323
|
+
try {
|
|
324
|
+
const wt2 = new WebTransport(url, opts)
|
|
325
|
+
await wt2.ready
|
|
326
|
+
await sendResume(wt2, { last_event_seq: lastSeenSeq })
|
|
327
|
+
// ResumeOk arrived → swap wt → wt2, rebind pumps
|
|
328
|
+
return
|
|
329
|
+
} catch (e) {
|
|
330
|
+
await new Promise(r => setTimeout(r, delay))
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// give up; surface terminal error
|
|
334
|
+
})
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
After `reconnect_window` lapses, server returns `EndpointNotProvisioned`
|
|
338
|
+
and the controller has to call `StartModule` again — the app's "start a
|
|
339
|
+
new call" flow.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## 10. Certificates
|
|
344
|
+
|
|
345
|
+
### 10.1 Production
|
|
346
|
+
|
|
347
|
+
Standard WebPKI cert chain. The browser uses the OS / WebPKI trust
|
|
348
|
+
store. Nothing special on the client side — omit
|
|
349
|
+
`serverCertificateHashes`.
|
|
350
|
+
|
|
351
|
+
### 10.2 Development (self-signed)
|
|
352
|
+
|
|
353
|
+
WebTransport supports `serverCertificateHashes` to trust a specific
|
|
354
|
+
cert without a CA chain, **but** there are hard constraints:
|
|
355
|
+
|
|
356
|
+
- Cert must use ECDSA-P256 (RSA not allowed).
|
|
357
|
+
- Cert validity ≤ 14 days.
|
|
358
|
+
- Both Subject and Issuer must be present.
|
|
359
|
+
- Hash algorithm must be `sha-256`.
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
async function sha256Bytes(pem) {
|
|
363
|
+
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
|
|
364
|
+
const der = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
|
365
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', der))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const certHash = await sha256Bytes(serverCertPem)
|
|
369
|
+
const wt = new WebTransport(url, {
|
|
370
|
+
serverCertificateHashes: [{ algorithm: 'sha-256', value: certHash.buffer }],
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Hand the dev cert (or just its sha-256) to the JS bundle out-of-band.
|
|
375
|
+
The dev deploy script (`scripts/deploy-dev.sh`) is the right place to
|
|
376
|
+
emit it.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## 11. Errors and error codes
|
|
381
|
+
|
|
382
|
+
All app-level errors arrive as a typed `MoqError` ControlMessage on the
|
|
383
|
+
bidi stream **before** the session is closed. Same enum as native — see
|
|
384
|
+
[`client-integration.md` §10](client-integration.md):
|
|
385
|
+
|
|
386
|
+
| Code | Meaning |
|
|
387
|
+
|---|---|
|
|
388
|
+
| `0x1001 EndpointNotProvisioned` | Token's `eid` does not match a live endpoint. |
|
|
389
|
+
| `0x1002 EndpointAlreadyConnected` | Another session holds this endpoint. |
|
|
390
|
+
| `0x1010 TokenInvalid` | Sig / iss / aud / schema check failed. |
|
|
391
|
+
| `0x1011 TokenExpired` | `exp` past. |
|
|
392
|
+
| `0x1012 TokenReplayed` | `jti` already seen in the cache. |
|
|
393
|
+
| `0x1020 ProtocolViolation` | Bad frame ordering, oversize, etc. |
|
|
394
|
+
| `0x1021 CapabilityMissing` | Verb requires a `cap` you don't have. |
|
|
395
|
+
| `0x1022 IllegalState` | Verb invalid in current state. |
|
|
396
|
+
| `0x1030 ResourceExhausted` | Server capacity hit. |
|
|
397
|
+
| `0x1031 ReplayLost` | Resume gap too wide. |
|
|
398
|
+
| `0x1040 SessionEnded` | Server closed the session cleanly. |
|
|
399
|
+
|
|
400
|
+
Transport-level failures (cert rejected, server unreachable, network
|
|
401
|
+
loss) surface as a rejection on `wt.ready` or `wt.closed` — no
|
|
402
|
+
`MoqError` because the session never opened or already dropped.
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## 12. Browser limitations to design around
|
|
407
|
+
|
|
408
|
+
| Limitation | Workaround |
|
|
409
|
+
|---|---|
|
|
410
|
+
| No `Origin` enforcement in WT API | Server doesn't gate on Origin — auth is JWT in the framed Connect, not cookies. |
|
|
411
|
+
| No custom request headers on the WT CONNECT | Auth rides the Connect ControlMessage, not headers. |
|
|
412
|
+
| WebCodecs Opus support varies by browser | Detect via `AudioEncoder.isConfigSupported`; fall back to a wasm Opus encoder if you need broader reach. |
|
|
413
|
+
| Datagram backpressure is implicit | Watch `wt.datagrams.writable.getWriter().ready` if you have a bursty sender; for 20 ms audio it's never the bottleneck. |
|
|
414
|
+
| Tab visibility / `requestAnimationFrame` throttling | Audio pumps must use timers (`setInterval` / `AudioWorklet` clock), not rAF. |
|
|
415
|
+
| Same-origin policy for fetch-based token retrieval | The token-mint endpoint must be CORS-enabled or same-origin as the JS bundle. |
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## 13. Minimal happy-path skeleton
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
async function startCall({ url, jwt, certHash /* optional */ }) {
|
|
423
|
+
// 1. Open session
|
|
424
|
+
const wt = new WebTransport(url, certHash
|
|
425
|
+
? { serverCertificateHashes: [{ algorithm: 'sha-256', value: certHash }] }
|
|
426
|
+
: undefined)
|
|
427
|
+
await wt.ready
|
|
428
|
+
|
|
429
|
+
// 2. Open control bidi sub-stream
|
|
430
|
+
const ctrl = await wt.createBidirectionalStream()
|
|
431
|
+
const writer = ctrl.writable.getWriter()
|
|
432
|
+
const reader = ctrl.readable.getReader()
|
|
433
|
+
|
|
434
|
+
// 3. Send Connect, await ConnectOk
|
|
435
|
+
await writer.write(encodeConnect({
|
|
436
|
+
request_id: 1n,
|
|
437
|
+
call_token: jwt,
|
|
438
|
+
sdk_name: 'web-sdk', sdk_version: '1.0.0',
|
|
439
|
+
offers: [{ class: 'Audio', purpose: 'Primary', codecs: ['Opus'] }],
|
|
440
|
+
frame_size_ms: 20,
|
|
441
|
+
}))
|
|
442
|
+
const connectOk = await readControlMessage(reader)
|
|
443
|
+
const { audioAlias, serverEpochUnix } = parseConnectOk(connectOk)
|
|
444
|
+
|
|
445
|
+
// 4. Spin up the four pumps
|
|
446
|
+
startBidiReadLoop(reader, dispatchEvent) // server-push + verb Oks
|
|
447
|
+
startDatagramSender(wt, audioAlias, serverEpochUnix) // mic → Opus → datagram
|
|
448
|
+
startDatagramReceiver(wt) // datagrams → decoders
|
|
449
|
+
startUniReceiver(wt) // DTMF uni streams
|
|
450
|
+
|
|
451
|
+
// 5. Wait for session end
|
|
452
|
+
wt.closed.then(info => onClosed(info), err => onError(err))
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
hangup: () => wt.close({ closeCode: 0, reason: '' }),
|
|
456
|
+
sendDtmf: (digit) => sendDtmfVerb(writer, digit),
|
|
457
|
+
hold: (held) => sendHoldVerb(writer, held),
|
|
458
|
+
mute: (scope) => sendMuteVerb(writer, scope),
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## 14. Quick reference
|
|
466
|
+
|
|
467
|
+
| Constant | Value |
|
|
468
|
+
|---|---|
|
|
469
|
+
| URL path | `/web` |
|
|
470
|
+
| Control stream | one bidi sub-stream, opened by the client right after `wt.ready` |
|
|
471
|
+
| First frame on the control stream | `Connect` (must) |
|
|
472
|
+
| Audio codec | Opus 48 kHz mono, 20 ms |
|
|
473
|
+
| Datagram frame | one `ObjectDatagram` per audio frame |
|
|
474
|
+
| Max framed control message | 64 KiB |
|
|
475
|
+
| `reconnect_window` | 30 s (server-side; Tier-3 Resume budget) |
|
|
476
|
+
| Token TTL | 4 h (server-issued) |
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## 15. Where to go next
|
|
481
|
+
|
|
482
|
+
- Wire-level reference: [`client-integration.md`](client-integration.md)
|
|
483
|
+
- Control message byte layout: [`moq-core/codec/src/wire/control.rs`](../../moq-core/codec/src/wire/control.rs)
|
|
484
|
+
- ObjectDatagram byte layout: [`moq-core/codec/src/wire/data_stream.rs`](../../moq-core/codec/src/wire/data_stream.rs)
|
|
485
|
+
- Proto schemas: [`ms-proto/proto/v1/`](../ms-proto/proto/v1/)
|
package/docs/contract.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Web SDK contract
|
|
2
|
+
|
|
3
|
+
This public SDK consumes the browser chat and session REST surfaces produced by
|
|
4
|
+
`/home/yl/workspace/platform/cx`. Public examples explain usage; this file is the
|
|
5
|
+
reciprocal cross-repository contract ledger.
|
|
6
|
+
|
|
7
|
+
## Interactive chat prompts
|
|
8
|
+
|
|
9
|
+
- Incoming ORPC messages decode `buttons: [{label,value,type}]` and
|
|
10
|
+
`gallery: [{title,description,image?,buttons}]`; a gallery image is
|
|
11
|
+
legitimately absent. The canonical producer and consumer registry is
|
|
12
|
+
`/home/yl/workspace/platform/cx/CONTRACT.md` (consumers of `buttons` /
|
|
13
|
+
`gallery`), reciprocated by the active `/home/yl/workspace/web/chat` consumer.
|
|
14
|
+
- The public `SendMessagePayload.buttonReply {value,label?}` is a local typed
|
|
15
|
+
adapter. It never rides the wire as a nested object: `ChatSession` maps
|
|
16
|
+
`value` to top-level ORPC `MessageRequest.value` and optional `label` (the
|
|
17
|
+
gallery card title) to `MessageRequest.galleryLabel`; the button caption
|
|
18
|
+
remains `text`. cx's reciprocal consumer registration is its Button / Gallery
|
|
19
|
+
reply-shape entry. This adapter supersedes the original Sprint BG proposal to
|
|
20
|
+
delete `buttonReply` while preserving the canonical wire exactly.
|
|
21
|
+
|
|
22
|
+
## Session directory continuity hardcut
|
|
23
|
+
|
|
24
|
+
- `GET /sessions` is parsed as `SessionSummary[]` and every row must contain
|
|
25
|
+
`active: boolean`. Missing or non-boolean `active` is a decode failure; there is
|
|
26
|
+
no compatibility default. The producer is `platform/cx` (`CONTRACT.md`,
|
|
27
|
+
"Session directory + history + resume") and the native mirror is
|
|
28
|
+
`workspace/apps/sdk/session/src/types.rs`.
|
|
29
|
+
- `active` is live-owner state, not stored session status. It is meaningful only
|
|
30
|
+
while list/bootstrap and chat ORPC Attach resolve to the same live cx owner.
|
|
31
|
+
- Browser auto-restore and browser push are intentionally absent. The in-workspace
|
|
32
|
+
`web/chat` consumer may display/list the decoded rows but must not turn `active`
|
|
33
|
+
into automatic background attachment.
|
|
34
|
+
|
|
35
|
+
## Release gate
|
|
36
|
+
|
|
37
|
+
Run typecheck, unit tests, and the production build, then rebuild the linked
|
|
38
|
+
`workspace/web/chat` consumer before publication. Publishing to npm remains an
|
|
39
|
+
owner action and is never part of an implementation spin.
|
|
40
|
+
|
|
41
|
+
## Provisioned receive-only voice join
|
|
42
|
+
|
|
43
|
+
The active in-workspace consumer is `workspace/web/cx` Insights Live. Its
|
|
44
|
+
page-owned supervision controller passes platform/cx's one-time media offer
|
|
45
|
+
directly to this seam, keeps the token in memory only, and uses
|
|
46
|
+
`enableCapture`/`releaseCapture` for Coach/Listen. Deployed gw route, policy,
|
|
47
|
+
certificate, and live-probe evidence remains the workspace LS-4 gate.
|
|
48
|
+
|
|
49
|
+
`joinSession({channel:'voice', sessionId, url, token,
|
|
50
|
+
voice:{receiveOnly:true}})` is the additive listen-only seam for a media offer
|
|
51
|
+
provisioned by another control plane. It still requires `initialize()` but does
|
|
52
|
+
not require `authenticate()` or `/config`.
|
|
53
|
+
|
|
54
|
+
The SDK starts the audio worklet and WebTransport receive path, awaits
|
|
55
|
+
`ConnectOk`, then sends `Mute(uplink)` and awaits the matching `MuteOk` before
|
|
56
|
+
firing `onConnected`. It never calls `getUserMedia` or enables capture on this
|
|
57
|
+
path. Server mute acknowledgement, not browser state, is the uplink-safety
|
|
58
|
+
boundary. Omitting the option preserves the existing capture-on-connect
|
|
59
|
+
behavior. A failed voice start removes its SessionManager entry and attempts to
|
|
60
|
+
dispose partial resources so the same provisioned id can be retried; a teardown
|
|
61
|
+
failure is reported explicitly without masking the original start failure.
|
|
62
|
+
|
|
63
|
+
`enableCapture(sessionId)` is the serialized Listen→Coach transition. It
|
|
64
|
+
acquires and enables the worklet pipeline while the endpoint is uplink-muted,
|
|
65
|
+
waits for a generation-correlated worklet acknowledgement, awaits
|
|
66
|
+
`Mute(none)`/`MuteOk`, and only then opens the local outbound datagram gate.
|
|
67
|
+
`releaseCapture(sessionId)` closes that gate immediately, awaits
|
|
68
|
+
`Mute(uplink)`/`MuteOk`, disables capture, disconnects capture/diagnostic nodes,
|
|
69
|
+
stops every microphone track, and nulls the stream. Permission, worklet, mute,
|
|
70
|
+
transport, terminal-event, and teardown races fail closed: late generations and
|
|
71
|
+
stale transport acknowledgements cannot reopen capture or resolve new requests.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Chat protocol: wire shape + extension rule
|
|
2
|
+
|
|
3
|
+
The chat wire is the **`chat.v1` protobuf schema** spoken over
|
|
4
|
+
oRPC-over-WebTransport (since 0.5.0; the SSE lane is deleted — there is
|
|
5
|
+
no fallback transport). The schema's canonical source is
|
|
6
|
+
`platform/cx/protos/chat.proto` in the Origon workspace, vendored into
|
|
7
|
+
this repo as `src/orpc-gen/chat_pb.ts`; the SDK decodes exactly the
|
|
8
|
+
arms that descriptor carries.
|
|
9
|
+
|
|
10
|
+
## The wire, in one table
|
|
11
|
+
|
|
12
|
+
| Direction | RPC (`chat.v1.Session/…`) | Payload |
|
|
13
|
+
|-----------|-------------------------------|---------|
|
|
14
|
+
| attach | `Attach` (server-streaming) | initial `AttachResponse {participantId, role}`, then one `ChatEvent` per frame |
|
|
15
|
+
| send | `Message` (unary) | `MessageRequest {clientMessageId, text?, html?, attachments, value?, galleryLabel?}` → the canonical `Message` echo |
|
|
16
|
+
| typing | `Typing` (unary) | `TypingRequest {state: "on"\|"off"}` |
|
|
17
|
+
| leave | `Leave` (unary) | empty; immediate server-side finalize |
|
|
18
|
+
|
|
19
|
+
`ChatEvent` is a oneof: `message`, `typing`, `participant_joined`,
|
|
20
|
+
`participant_left`, `session_ended`, `heartbeat`. The SDK surfaces
|
|
21
|
+
`message`, `typing` and `session_ended`; everything else — and any arm
|
|
22
|
+
it does not know — is consumed and dropped. That tolerance is the
|
|
23
|
+
forward-compat contract.
|
|
24
|
+
|
|
25
|
+
## The extension rule: proto arms first
|
|
26
|
+
|
|
27
|
+
**A chat feature does not exist until it has a proto arm.** The path for
|
|
28
|
+
any new server→client event or client→server field is:
|
|
29
|
+
|
|
30
|
+
1. **Proto** — the arm lands in cx's `chat.proto` (a new `ChatEvent`
|
|
31
|
+
oneof arm, or a new `MessageRequest` field) and cx emits/reads it.
|
|
32
|
+
2. **Re-vendor** — the monorepo's `web/kit/scripts/ship-orpc.sh`
|
|
33
|
+
regenerates `src/orpc-gen/` here (never hand-edited).
|
|
34
|
+
3. **SDK surface** — a decode arm in `src/chat/orpc.ts` and, if the
|
|
35
|
+
consumer needs it, a callback/payload field.
|
|
36
|
+
|
|
37
|
+
The SSE era did this backwards: the SDK shipped designed-ahead callbacks
|
|
38
|
+
(`onControlUpdated`, `onToolCalls`, `onAssistantModeChanged`) and
|
|
39
|
+
`sendMessage` fields for events no backend ever emitted. Those inert
|
|
40
|
+
surfaces were **deleted with the transport** (owner decision,
|
|
41
|
+
2026-08-06). If control-handoff, tool-calls or mode-switch semantics
|
|
42
|
+
return, they re-enter through the three steps above.
|
|
43
|
+
|
|
44
|
+
## Streaming AI replies (live behavior)
|
|
45
|
+
|
|
46
|
+
For AI responses the backend streams the reply as multiple `message`
|
|
47
|
+
events carrying the **same `id`** with `state: "streaming"`, terminated
|
|
48
|
+
by one final event with `state: "completed"`.
|
|
49
|
+
|
|
50
|
+
- First chunk with a new `id` → `onMessageAdded(message)` — the
|
|
51
|
+
consumer renders a new row.
|
|
52
|
+
- Subsequent chunks with the same `id` → `onMessageUpdated({id, message})`.
|
|
53
|
+
- `text`/`html` is the complete cumulative content so far, **not a
|
|
54
|
+
delta** — consumers replace, not append.
|
|
55
|
+
- One-shot messages are a single event with `state: "completed"`; an
|
|
56
|
+
unknown/absent `state` degrades to `"completed"`.
|
|
57
|
+
|
|
58
|
+
## Prompt buttons + gallery (live behavior)
|
|
59
|
+
|
|
60
|
+
A `message` may carry `buttons` (`MessageButton {label, value, type}`)
|
|
61
|
+
and/or `gallery` (`MessageCard {title, description, image?, buttons}`).
|
|
62
|
+
The reply leg is `SendMessagePayload.buttonReply {value, label?}`, which
|
|
63
|
+
the SDK maps onto the wire's top-level `value` / `galleryLabel`.
|
|
64
|
+
|
|
65
|
+
## Role mapping
|
|
66
|
+
|
|
67
|
+
The SDK's `MessageRole` is `'ai' | 'external' | 'user' | 'system'`;
|
|
68
|
+
unknown wire roles degrade to `'external'` (the visitor side is the safe
|
|
69
|
+
default). Mapping vs. the legacy chat-sdk:
|
|
70
|
+
|
|
71
|
+
| Legacy role | New role | Who it represents |
|
|
72
|
+
|--------------|--------------|-------------------------------------------------------------|
|
|
73
|
+
| `assistant` | `ai` | AI bot |
|
|
74
|
+
| `user` | `external` | End-user / customer (the visitor in the chat widget) |
|
|
75
|
+
| `supervisor` | `user` | Staff / internal operator (the human agent on the dashboard)|
|
|
76
|
+
| `system` | `system` | System-injected messages |
|
|
77
|
+
|
|
78
|
+
## Outbound payload: what actually rides the wire
|
|
79
|
+
|
|
80
|
+
`sendMessage`'s wire carrier is `MessageRequest`:
|
|
81
|
+
`clientMessageId` (SDK-minted idempotency key), `text`, `html`,
|
|
82
|
+
`attachments` (ids/names only — URLs are server-assigned on the echo),
|
|
83
|
+
`value`, `galleryLabel`. The public payload surface also accepts the local
|
|
84
|
+
typed `buttonReply {value,label?}` adapter and maps it to those two top-level
|
|
85
|
+
wire fields; the nested adapter never rides ORPC. Two other local-only fields
|
|
86
|
+
are stripped before send: `role` (the provisional-row hint) and
|
|
87
|
+
`attachments[].localUrl` (preview blob). The
|
|
88
|
+
SSE-era fields with no wire carrier (`type`/`results`/`meta`/`context`/
|
|
89
|
+
`createSystem`/`mode`) were deleted with 0.5.0 (owner decision,
|
|
90
|
+
2026-08-07) — a new outbound field starts with a proto arm, per the
|
|
91
|
+
extension rule above.
|