@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 ADDED
@@ -0,0 +1,245 @@
1
+ # @origonai/web-sdk
2
+
3
+ Origon Web SDK — chat and voice session client for browsers.
4
+
5
+ > **Status**: v0.5.0 (unpublished). Chat (orpc over WebTransport) and
6
+ > voice (WebTransport + WASM Opus) are both implemented — see
7
+ > [Status](#status). **WebTransport is required for both channels**;
8
+ > browsers without it (Safari, Firefox today) are unsupported.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @origonai/web-sdk
14
+ # or
15
+ pnpm add @origonai/web-sdk
16
+ ```
17
+
18
+ ## Quick start (chat)
19
+
20
+ ```ts
21
+ import { getSessionManager } from '@origonai/web-sdk'
22
+
23
+ const client = getSessionManager()
24
+
25
+ client.initialize({
26
+ endpoint: 'https://your-backend.example.com',
27
+ token: '<bearer>', // optional; omit for anonymous users
28
+ userId: '<your-id>', // optional; auto-generated UUID v7 if not provided
29
+ bundleId: 'com.example.app',
30
+ })
31
+
32
+ await client.authenticate()
33
+
34
+ client.setCallbacks({
35
+ onMessageAdded: (sessionId, channel, msg) => render(msg),
36
+ onMessageUpdated: (sessionId, channel, { id, message }) => update(id, message),
37
+ onTyping: (sessionId, channel, isTyping) => setTyping(isTyping),
38
+ onDisconnected: (sessionId, channel, { reason }) => console.log('closed:', reason),
39
+ })
40
+
41
+ const { sessionId } = await client.startSession({ channel: 'chat' })
42
+
43
+ await client.sendMessage(sessionId, { text: 'Hello!' })
44
+
45
+ client.notifyTyping(sessionId) // call on each keystroke; SDK debounces
46
+
47
+ await client.endSession(sessionId)
48
+ ```
49
+
50
+ ## Public API
51
+
52
+ ### Lifecycle
53
+ - `initialize(credentials)` — set endpoint, token, userId, bundleId, attributes
54
+ - `authenticate()` — POST `/config`, cache and return server config
55
+ - `setCallbacks(callbacks)` — register event handlers (see below)
56
+ - `setAttributes(attrs)` — update session-level attributes forwarded on `/session/start`
57
+
58
+ ### Sessions
59
+ - `startSession({ channel, sessionId?, data? })` — originate a chat or voice session (issues `POST /session/start`)
60
+ - `joinSession({ channel, sessionId, url, token, voice? })` — attach to a session provisioned out of band, skipping `POST /session/start`; `voice.receiveOnly` starts playout with no microphone and awaits server uplink mute before connecting (see [Joining a provisioned session](#joining-a-provisioned-session))
61
+ - `endSession(sessionId)` — close a session
62
+ - `endAllSessions()` — close every active session
63
+ - `migrateSessionId(oldId, newId)` — re-key a live session whose id the control plane reassigned mid-call, without a media re-dial
64
+ - `activeSessionIds()` — list active sessions
65
+ - `getSessions()` — GET `/sessions` (prior sessions; every `SessionSummary`
66
+ includes required live-owner `active: boolean`)
67
+ - `getSession(sessionId)` — GET `/session/{id}` (transcript + control)
68
+
69
+ ### Chat
70
+ - `sendMessage(sessionId, payload)` — post a message; SDK fires `onMessageAdded` immediately with `status: 'sending'`, then `onMessageUpdated` with `'delivered'` or `'failed'` after the server acks
71
+ - `notifyTyping(sessionId)` — call on each keystroke; SDK debounces (3s window) and emits typing on/off automatically
72
+ - `stopTyping(sessionId)` — flush typing-off immediately
73
+
74
+ ### Attachments
75
+ Attachments are **widget-scoped, not session-scoped** — both verbs address the
76
+ `endpoint` given to `initialize()` and need no live session, so a file can be the
77
+ first thing a visitor sends.
78
+
79
+ - `uploadAttachment(file, { uploadId, onProgress? })` — streamed upload with progress
80
+ - `deleteAttachment(idOrUploadId)` — dual-purpose: cancels in-flight uploads if `idOrUploadId` matches a pending `uploadId`; otherwise DELETEs the stored attachment
81
+
82
+ ## Callbacks
83
+
84
+ `active` is directory liveness, not stored status. The SDK requires the field
85
+ but does not auto-restore browser chats; opening or resuming a row remains a host
86
+ action.
87
+
88
+ All callbacks receive `sessionId, channel` as the first two args so one
89
+ consumer can host multiple sessions.
90
+
91
+ ```ts
92
+ {
93
+ // Chat
94
+ onMessageAdded?: (sessionId, channel, message)
95
+ onMessageUpdated?: (sessionId, channel, { id, message })
96
+ onTyping?: (sessionId, channel, isTyping)
97
+ onSessionUpdated?: (sessionId, channel)
98
+
99
+ // Lifecycle (both channels — chat terminals land on onDisconnected too)
100
+ onConnected?: (sessionId, channel)
101
+ onDisconnected?: (sessionId, channel, { reason })
102
+
103
+ // Voice
104
+ onReconnecting?: (sessionId, channel, { attempt, reason })
105
+ onReconnected?: (sessionId, channel)
106
+ onPeerAttached?: (sessionId, channel, { peerEndpointId, alias })
107
+ onPeerDetached?: (sessionId, channel, { peerEndpointId, alias })
108
+ onCallError?: (sessionId, channel, error)
109
+ }
110
+ ```
111
+
112
+ ## Message roles
113
+
114
+ | Role | Sender |
115
+ |--------------|-------------------------------------------------------------------|
116
+ | `ai` | AI bot / assistant |
117
+ | `external` | End-user / customer (default for `sendMessage` from a widget) |
118
+ | `user` | Internal staff / operator (default for `sendMessage` from a dashboard) |
119
+ | `system` | System-injected messages |
120
+
121
+ The outbound `payload.role` is a local-only hint for the provisional
122
+ `onMessageAdded` row; it's stripped before POST.
123
+
124
+ ## Errors
125
+
126
+ REST-path errors are `ClientError` with a structured shape:
127
+
128
+ ```ts
129
+ class ClientError extends Error {
130
+ kind: 'notInitialized' | 'noSession' | 'session' | 'missingField' |
131
+ 'serverUnavailable' | 'http' | 'attachment' | 'cancelled' | 'other'
132
+ status?: number // HTTP status when kind === 'http' or 'serverUnavailable'
133
+ code?: string // server error code (kind === 'http') or attachment policy code
134
+ message: string
135
+ }
136
+ ```
137
+
138
+ Server error envelopes (`{ "error": { "code", "message" } }`) parse
139
+ through to `ClientError.http`.
140
+
141
+ Chat **wire refusals** (the orpc lane) throw `OrpcError` instead, with
142
+ the server's numeric status preserved:
143
+
144
+ ```ts
145
+ class OrpcError extends Error {
146
+ code: number // foundation ErrorCode, e.g. 0x0001 session_not_found
147
+ reason: string // the server's message
148
+ }
149
+ ```
150
+
151
+ ## Voice (WebTransport + WASM Opus)
152
+
153
+ ```ts
154
+ client.initialize({
155
+ endpoint: 'https://your-control-plane',
156
+ token: '<bearer>',
157
+ })
158
+
159
+ const { sessionId } = await client.startSession({ channel: 'voice' })
160
+ await client.setMute(sessionId, 'uplink') // 'uplink' | 'downlink' | 'both' | 'none'
161
+
162
+ const off = client.subscribeAudioStats(sessionId, ({ outboundLevel, inboundLevel }) => {
163
+ // VU meters at ~5 Hz
164
+ })
165
+
166
+ await client.endSession(sessionId)
167
+ ```
168
+
169
+ Voice requires:
170
+
171
+ - WebTransport — Chromium / Edge today. (Chat requires it too — it is
172
+ the SDK-wide floor; see `docs/voice.md`.)
173
+ - A WASM audio engine — ships **prebuilt and committed** at `src/voice/audio/wasm-gen/`; no Rust or wasm toolchain needed (see [`docs/voice.md`](docs/voice.md)).
174
+ - A bundler that relocates `new URL(..., import.meta.url)` assets (Vite ≥ 6.3.3) — the SDK's two voice assets are emitted by *your* build. Non-relocating pipelines use the `assetBaseUrl` copy-step instead. If the page sets a CSP, `script-src` needs `'wasm-unsafe-eval'`. Details: [`docs/voice.md`](docs/voice.md).
175
+
176
+ The browser opens a single WebTransport to `<media-server>/web`, sends a B2BUA `Connect` over the control sub-stream, and pumps 20 ms Opus frames as `ObjectDatagram`s with `group_id = floor(Date.now()/1000) - serverEpochUnix`. Reconnect is Tier-3 only (`Resume` verb) with `[100, 250, 500, 1000]` ms backoff.
177
+
178
+ ## Joining a provisioned session
179
+
180
+ `startSession` originates a session by calling `POST /session/start` itself.
181
+ Consumers that are instead *handed* a session out of band — a Connect agent
182
+ answering a pushed call offer, a supervisor joining a live call — attach with
183
+ `joinSession`. The `{ sessionId, url, token }` come from the caller's own
184
+ control plane; without additional join options, the media/chat path is
185
+ byte-for-byte identical thereafter.
186
+
187
+ ```ts
188
+ client.initialize({ endpoint: 'https://your-control-plane' })
189
+ client.setCallbacks({ onConnected, onPeerAttached, onDisconnected })
190
+
191
+ // `offer` = { sessionId, url, token }, delivered by your control channel.
192
+ await client.joinSession({ channel: 'voice', ...offer })
193
+
194
+ // Listen-only provisioned leg: starts the worklet + receive transport, never
195
+ // asks for microphone permission, and fires onConnected only after MuteOk.
196
+ await client.joinSession({
197
+ channel: 'voice',
198
+ ...listenOffer,
199
+ voice: { receiveOnly: true },
200
+ })
201
+
202
+ // Listen → Coach: the SDK acquires capture while server-muted, then unmutes.
203
+ await client.enableCapture(listenOffer.sessionId)
204
+
205
+ // Coach → Listen: the SDK waits for the server mute acknowledgement before
206
+ // disabling capture and releasing the microphone, worklet graph, and diagnostics.
207
+ await client.releaseCapture(listenOffer.sessionId)
208
+
209
+ // If the control plane later reassigns the session id (e.g. a warm-transfer or
210
+ // conference completes), re-key in place — the media endpoint is not re-dialed:
211
+ client.migrateSessionId(offer.sessionId, newId)
212
+ ```
213
+
214
+ `initialize()` is still required (the endpoint stays mandatory), but
215
+ `authenticate()` / `POST /config` is **not** needed on this path. The SDK is
216
+ control-plane-agnostic: it never names or imports the source of the
217
+ `{ sessionId, url, token }` — a consumer that wires a specific producer's offer
218
+ (e.g. a `SessionOffered` event) owns and registers that coupling.
219
+
220
+ `voice.receiveOnly` changes only provisioned joins. Ordinary `startSession`
221
+ and `joinSession` calls without the option retain microphone capture. The
222
+ receive-only sequence is Worklet/transport → `ConnectOk` → `Mute(uplink)` →
223
+ `MuteOk` → `onConnected`; `getUserMedia` is never called.
224
+
225
+ Capture transitions are serialized and teardown-safe. If permission, capture,
226
+ or unmute fails, the SDK returns to uplink-muted state and releases the local
227
+ microphone. A late permission result cannot revive capture after Listen or
228
+ disconnect wins.
229
+
230
+ ## Status
231
+
232
+ - ✅ **Phase 1 — Chat** — orpc over WebTransport (protobuf wire),
233
+ multi-session, attachments, overflow replay
234
+ - ✅ **Phase 2 — Voice** — WebTransport, B2BUA verb codec, WASM Opus engine, reconnect
235
+ - ✅ **Phase 3 — Voice polish** — audio stats subscription, peer attach/detach
236
+
237
+ ## Extending the chat protocol
238
+
239
+ The chat wire is the `chat.v1` protobuf schema; a new event kind
240
+ requires a proto arm before any SDK surface can exist for it. See
241
+ [`docs/new-chat-protocol.md`](docs/new-chat-protocol.md).
242
+
243
+ ## License
244
+
245
+ MIT
@@ -0,0 +1 @@
1
+ (function(){"use strict";const M=globalThis;if(typeof M.TextDecoder>"u"){class n{constructor(t,r){}decode(t){if(!t)return"";const r=t instanceof Uint8Array?t:t instanceof ArrayBuffer?new Uint8Array(t):new Uint8Array(t.buffer,t.byteOffset,t.byteLength);let s="",i=0;for(;i<r.length;){const o=r[i];if(o<128)s+=String.fromCharCode(o),i+=1;else if((o&224)===192)s+=String.fromCharCode((o&31)<<6|r[i+1]&63),i+=2;else if((o&240)===224)s+=String.fromCharCode((o&15)<<12|(r[i+1]&63)<<6|r[i+2]&63),i+=3;else{const c=(o&7)<<18|(r[i+1]&63)<<12|(r[i+2]&63)<<6|r[i+3]&63;s+=String.fromCodePoint(c),i+=4}}return s}}M.TextDecoder=n}if(typeof M.TextEncoder>"u"){class n{encode(t=""){const r=[];for(let s=0;s<t.length;s++){let i=t.charCodeAt(s);if(i>=55296&&i<=56319&&s+1<t.length){const o=t.charCodeAt(s+1);o>=56320&&o<=57343&&(i=65536+(i-55296<<10)+(o-56320),s++)}i<128?r.push(i):i<2048?r.push(192|i>>6,128|i&63):i<65536?r.push(224|i>>12,128|i>>6&63,128|i&63):r.push(240|i>>18,128|i>>12&63,128|i>>6&63,128|i&63)}return new Uint8Array(r)}}M.TextEncoder=n}class T{__destroy_into_raw(){const e=this.__wbg_ptr;return this.__wbg_ptr=0,O.unregister(this),e}free(){const e=this.__destroy_into_raw();a.__wbg_audiopipeline_free(e,0)}add_track(e,t){const r=$(t,a.__wbindgen_malloc,a.__wbindgen_realloc),s=p,i=a.audiopipeline_add_track(this.__wbg_ptr,e,r,s);if(i[1])throw D(i[0])}disable_capture(){a.audiopipeline_disable_capture(this.__wbg_ptr)}enable_capture(e){const t=a.audiopipeline_enable_capture(this.__wbg_ptr,e);if(t[1])throw D(t[0])}feedback_pending(){return a.audiopipeline_feedback_pending(this.__wbg_ptr)!==0}ingest_uplink_feedback(e){const t=C(e,a.__wbindgen_malloc),r=p;a.audiopipeline_ingest_uplink_feedback(this.__wbg_ptr,t,r)}constructor(e,t){const r=a.audiopipeline_new(e,t);return this.__wbg_ptr=r,O.register(this,this.__wbg_ptr,this),this}process(e,t,r,s,i,o){const c=L(e,a.__wbindgen_malloc),u=p;var k=L(t,a.__wbindgen_malloc),l=p;const h=C(r,a.__wbindgen_malloc),A=p;var Q=C(s,a.__wbindgen_malloc),Z=p;return a.audiopipeline_process(this.__wbg_ptr,c,u,k,l,t,h,A,Q,Z,s,i,o)>>>0}remove_track(e){a.audiopipeline_remove_track(this.__wbg_ptr,e)}set_server_epoch_unix(e){a.audiopipeline_set_server_epoch_unix(this.__wbg_ptr,e)}stats(){let e,t;try{const r=a.audiopipeline_stats(this.__wbg_ptr);return e=r[0],t=r[1],E(r[0],r[1])}finally{a.__wbindgen_free(e,t,1)}}take_feedback(){const e=a.audiopipeline_take_feedback(this.__wbg_ptr);var t=B(e[0],e[1]).slice();return a.__wbindgen_free(e[0],e[1]*1,1),t}}Symbol.dispose&&(T.prototype[Symbol.dispose]=T.prototype.free);function j(){return{__proto__:null,"./origon_web_audio_bg.js":{__proto__:null,__wbg___wbindgen_copy_to_typed_array_4db0cbe2cc60dbee:function(e,t,r){new Uint8Array(r.buffer,r.byteOffset,r.byteLength).set(B(e,t))},__wbg___wbindgen_throw_344f42d3211c4765:function(e,t){throw new Error(E(e,t))},__wbindgen_cast_0000000000000001:function(e,t){return E(e,t)},__wbindgen_init_externref_table:function(){const e=a.__wbindgen_externrefs,t=e.grow(4);e.set(0,void 0),e.set(t+0,void 0),e.set(t+1,null),e.set(t+2,!0),e.set(t+3,!1)}}}}const O=typeof FinalizationRegistry>"u"?{register:()=>{},unregister:()=>{}}:new FinalizationRegistry(n=>a.__wbg_audiopipeline_free(n,1));function B(n,e){return n=n>>>0,f().subarray(n/1,n/1+e)}let y=null;function q(){return(y===null||y.byteLength===0)&&(y=new Float32Array(a.memory.buffer)),y}function E(n,e){return N(n>>>0,e)}let w=null;function f(){return(w===null||w.byteLength===0)&&(w=new Uint8Array(a.memory.buffer)),w}function C(n,e){const t=e(n.length*1,1)>>>0;return f().set(n,t/1),p=n.length,t}function L(n,e){const t=e(n.length*4,4)>>>0;return q().set(n,t/4),p=n.length,t}function $(n,e,t){if(t===void 0){const c=m.encode(n),u=e(c.length,1)>>>0;return f().subarray(u,u+c.length).set(c),p=c.length,u}let r=n.length,s=e(r,1)>>>0;const i=f();let o=0;for(;o<r;o++){const c=n.charCodeAt(o);if(c>127)break;i[s+o]=c}if(o!==r){o!==0&&(n=n.slice(o)),s=t(s,r,r=o+n.length*3,1)>>>0;const c=f().subarray(s+o,s+r),u=m.encodeInto(n,c);o+=u.written,s=t(s,r,o,1)>>>0}return p=o,s}function D(n){const e=a.__wbindgen_externrefs.get(n);return a.__externref_table_dealloc(n),e}let v=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});v.decode();const G=2146435072;let I=0;function N(n,e){return I+=e,I>=G&&(v=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),v.decode(),I=e),v.decode(f().subarray(n,n+e))}const m=new TextEncoder;"encodeInto"in m||(m.encodeInto=function(n,e){const t=m.encode(n);return e.set(t),{read:n.length,written:t.length}});let p=0,a;function Y(n,e){return a=n.exports,y=null,w=null,a.__wbindgen_start(),a}async function H(n,e){if(typeof Response=="function"&&n instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(n,e)}catch(s){if(n.ok&&t(n.type)&&n.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",s);else throw s}const r=await n.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(n,e);return r instanceof WebAssembly.Instance?{instance:r,module:n}:r}function t(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function J(n){if(a!==void 0)return a;n!==void 0&&(Object.getPrototypeOf(n)===Object.prototype?{module_or_path:n}=n:console.warn("using deprecated parameters for the initialization function; pass a single object instead"));const e=j();(typeof n=="string"||typeof Request=="function"&&n instanceof Request||typeof URL=="function"&&n instanceof URL)&&(n=fetch(n));const{instance:t,module:r}=await H(await n,e);return Y(t)}const V=new Uint8Array(0);let _=null,d=[],b=0,z=64*1024,F=0,x=null,R=0,U=!1,S=null,W=null,g=null;async function X(n,e,t){await J({module_or_path:n}),_=new T(e,t)}function P(n){if(n.length===0)return 0;let e=0;for(let t=0;t<n.length;t++)e+=n[t]*n[t];return Math.sqrt(e/n.length)}class K extends AudioWorkletProcessor{constructor(){super(),this.initialized=!1,this.initFailed=!1,this.pending=[],this.port.onmessage=e=>this.handleMessage(e.data)}handleMessage(e){if(!this.initialized&&e.type!=="init"){if(this.initFailed||e.type==="datagram"){e.type==="datagram"&&F++;return}this.pending.push(e);return}switch(e.type){case"init":{const t=e.wasmBytes,r=e.recvCapacity,s=e.sendCapacity,i=e.sampleRate,o=e.ptimeMs;X(t,i,o).then(()=>{z=r,x=new Uint8Array(s),this.initialized=!0;for(const c of this.pending)this.handleMessage(c);this.pending=[],this.port.postMessage({type:"ready"})}).catch(c=>{this.initFailed=!0,this.pending=[],this.port.postMessage({type:"error",message:String(c)})});break}case"datagram":{const t=e.frame;for(d.push(t),b+=t.length;b>z&&d.length>0;)b-=d.shift().length,F++;break}case"set_server_epoch_unix":_?.set_server_epoch_unix(BigInt(e.serverEpochUnix));break;case"add_track":try{_?.add_track(e.trackAlias,e.peerEndpointId??"")}catch(t){this.port.postMessage({type:"error",message:`add_track: ${String(t)}`})}break;case"remove_track":_?.remove_track(e.trackAlias);break;case"uplink_feedback":_?.ingest_uplink_feedback(e.payload);break;case"enable_capture":try{if(!_)throw new Error("pipeline unavailable");_.enable_capture(e.trackAlias),S=e.trackAlias,W=e.generation,g=null,this.port.postMessage({type:"capture_enabled",generation:e.generation,commandId:e.commandId})}catch(t){this.port.postMessage({type:"capture_enable_error",generation:e.generation,commandId:e.commandId,message:`enable_capture: ${String(t)}`})}break;case"open_capture":S!==null&&W===e.generation?(g=e.generation,this.port.postMessage({type:"capture_opened",generation:g,commandId:e.commandId})):this.port.postMessage({type:"capture_enable_error",generation:e.generation,commandId:e.commandId,message:"open_capture: capture generation is not enabled"});break;case"disable_capture":_?.disable_capture(),S=null,W=null,g=null;break;case"subscribe_stats":U=!0;break;case"unsubscribe_stats":U=!1;break}}process(e,t){if(!_||!x)return!0;const r=e[0]?.[0]??new Float32Array(0),s=t[0]?.[0];if(!s)return!0;let i;if(d.length===0)i=V;else if(d.length===1)i=d[0],d=[],b=0;else{i=new Uint8Array(b);let l=0;for(const h of d)i.set(h,l),l+=h.length;d=[],b=0}const o=Date.now(),c=Math.floor(o/4294967296)>>>0,u=o>>>0>>>0;let k=0;try{k=_.process(r,s,i,x,c,u)}catch(l){return this.port.postMessage({type:"error",message:`process failed: ${l instanceof Error?l.stack??l.message:String(l)}`}),!1}if(k>0&&g!==null){const l=x.slice(0,k);this.port.postMessage({type:"datagram_out",generation:g,payload:l},[l.buffer])}if(_.feedback_pending()){const l=_.take_feedback();l.length>0&&this.port.postMessage({type:"downlink_feedback",payload:l},[l.buffer])}if(R++,U&&R>=187){R=0;const l=P(r),h=P(s);let A;try{A=JSON.parse(_.stats())}catch{A=null}this.port.postMessage({type:"stats",data:{pipeline:A,captureAlias:S,outboundLevel:l,inboundLevel:h,droppedInbound:F}})}return!0}}registerProcessor("origon-audio-processor",K)})();