farvex 1.0.0 → 2.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # farvex
2
2
 
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - Add a first-class current-session controller for browser media sessions.
8
+
9
+ This release introduces `client.session`, `useCurrentSession`, `useCurrentSessionActions`, and `SessionRoom` so consumers can start, accept, join, end, and render the current media session without maintaining separate join-grant state.
10
+
11
+ Breaking changes:
12
+ - `useSession()` and `sessions.get()` now require an explicit session ID and no longer infer the newest non-ended session.
13
+ - Consumers should use `client.session.start()` and `client.session.accept()` for user-facing media flows instead of low-level `client.sessions.start()` / `client.sessions.accept()`.
14
+ - Current session state is now represented by the SDK's `CurrentSession` snapshot rather than consumer-local LiveKit token state.
15
+
16
+ ## 1.0.1
17
+
18
+ ### Patch Changes
19
+
20
+ - Fix host client disposal, expire stale incoming invites, and export host start input types for frontend integrations.
21
+
3
22
  ## 1.0.0
4
23
 
5
24
  ### Major Changes
package/README.md CHANGED
@@ -1,17 +1,16 @@
1
1
  # farvex
2
2
 
3
3
  Web SDK for Callpad voice sessions. It wraps the generated session HTTP client,
4
- Centrifugo realtime updates, and LiveKit re-exports. Calls history, notes,
5
- wrap-up, billing, metrics, and recording playback should use the normal REST
6
- API clients.
4
+ Centrifugo realtime updates, a current browser session controller, React hooks,
5
+ and LiveKit helpers.
7
6
 
8
7
  ## Exports
9
8
 
10
- | Entry | Purpose |
11
- | ---------------- | ------------------------------------------- |
12
- | `farvex` | Session client factories and SDK/API types. |
13
- | `farvex/react` | React provider and focused session hooks. |
14
- | `farvex/livekit` | LiveKit React/client re-exports. |
9
+ | Entry | Purpose |
10
+ | ---------------- | ----------------------------------------- |
11
+ | `farvex` | Client factories and SDK/API types. |
12
+ | `farvex/react` | React provider and focused session hooks. |
13
+ | `farvex/livekit` | LiveKit re-exports and `SessionRoom`. |
15
14
 
16
15
  ## Install
17
16
 
@@ -20,309 +19,236 @@ npm install farvex livekit-client @livekit/components-react
20
19
  ```
21
20
 
22
21
  `livekit-client`, `@livekit/components-react`, `react`, and `react-dom` are
23
- peer dependencies. Install the LiveKit packages only in apps that render call
24
- media.
22
+ peer dependencies. Install the LiveKit packages only in apps that render media.
25
23
 
26
- ## Setup
27
-
28
- The SDK needs these values from the app:
29
-
30
- | Value | Description |
31
- | -------- | ----------------------------------------------- |
32
- | `apiUrl` | Base URL for the Callpad API. |
33
- | `userId` | Callpad user ID for the signed-in app user. |
34
- | `auth` | Function that returns a bearer token on demand. |
35
- | `vendor` | Vendor slug. Required by host clients. |
36
-
37
- `getAccessToken` is called before API requests and when realtime auth needs a
38
- fresh token. Keep token minting in your app backend or existing auth layer; the
39
- browser client should only receive a user-scoped token. The examples below
40
- assume `getAccessToken` already exists in the app.
24
+ ## Client Setup
41
25
 
42
26
  ```ts
43
- import { createSessionClient } from "farvex";
27
+ import { createHostClient } from "farvex";
44
28
 
45
- const callpad = createSessionClient({
29
+ const client = createHostClient({
46
30
  apiUrl: "https://api.callpad.example.com",
47
- userId: currentUser.id,
31
+ vendor: "acme",
32
+ userId: user.id,
48
33
  auth: {
49
34
  getAccessToken,
50
- onUnauthorized: () => {
51
- auth.signOut();
52
- },
35
+ onUnauthorized: () => auth.signOut(),
53
36
  },
54
37
  });
55
- ```
56
38
 
57
- Call `connect()` once when the voice UI mounts. This opens the realtime
58
- connection and keeps `sessions`, `invites`, and `status` updated. Call
59
- `disconnect()` when the user leaves the voice UI, or `dispose()` when the client
60
- will not be reused.
61
-
62
- ```ts
63
- await callpad.connect();
64
- await callpad.sessions.sync();
39
+ await client.connect();
40
+ await client.sessions.sync(undefined, { mode: "replaceVisible" });
65
41
  ```
66
42
 
67
- SDK methods throw `CallpadError` for API problem responses. They do not return a
68
- custom result wrapper.
43
+ `getAccessToken` is called before REST requests and realtime token refreshes.
44
+ SDK methods throw `CallpadError` for API problem responses.
69
45
 
70
- ## Customer App
46
+ ## API Shape
71
47
 
72
- Customer apps use the common session client:
48
+ Use `client.session` for the current browser media session lifecycle:
73
49
 
74
50
  ```ts
75
- import { createSessionClient } from "farvex";
76
-
77
- const callpad = createSessionClient({
78
- apiUrl: "https://api.callpad.example.com",
79
- userId: customer.id,
80
- auth: { getAccessToken },
51
+ const current = await client.session.start({
52
+ target: { type: "customer", userId: customer.id },
81
53
  });
82
54
 
83
- await callpad.connect();
55
+ await client.session.end();
56
+ ```
84
57
 
85
- const started = await callpad.sessions.start({
86
- vendor: "acme",
87
- target: { type: "vendor_user", userId: 42 },
88
- });
58
+ Use `client.sessions` for the low-level collection/data API:
89
59
 
90
- if (!started.join) {
91
- throw new Error("This session did not return a media grant");
92
- }
60
+ ```ts
61
+ const sessions = await client.sessions.sync();
62
+ const session = client.sessions.get(current.sessionId);
63
+ const canRecord = session ? client.sessions.can(session, "startRecording") : false;
93
64
  ```
94
65
 
95
- `started.session` is the current `VoiceSession`. `started.join` is a LiveKit
96
- grant for the caller when the current user should join the media room.
66
+ The backend can support many live sessions. The browser SDK defaults to one
67
+ current media session per client instance. `client.session.start()` and
68
+ `client.session.accept()` use `rejectWhileBusy` by default, so starting or
69
+ accepting while a non-terminal current session exists fails locally with
70
+ `session.busy`. Pass `{ behavior: "allowParallel" }` only when intentionally
71
+ creating and selecting another backend session.
97
72
 
98
- ### Customer Calls A Vendor User
73
+ `StartedSession.join` from the generated API is nullable. The high-level
74
+ `client.session` methods require a media grant by default and fail with
75
+ `session.join_unavailable` if the backend creates/accepts a session but does not
76
+ return a browser media grant.
99
77
 
100
- This example starts an app-to-app call from a customer to a specific vendor user
101
- and renders the audio room after the session is created.
78
+ ## React Usage
79
+
80
+ Import `farvex/react` from client components.
102
81
 
103
82
  ```tsx
104
83
  "use client";
105
84
 
106
- import { useMemo, useState } from "react";
107
- import { createSessionClient, type SessionClient, type VoiceJoinGrant } from "farvex";
108
- import { CallpadProvider, useCallpad } from "farvex/react";
109
- import { LiveKitRoom, RoomAudioRenderer } from "farvex/livekit";
110
-
111
- const vendor = "acme";
112
- const vendorUserId = 42;
85
+ import { useMemo, type ReactNode } from "react";
86
+ import { createHostClient, type HostClient } from "farvex";
87
+ import { CallpadProvider } from "farvex/react";
113
88
 
114
- export const CustomerVoice = ({ customerId }: { customerId: number }) => {
115
- const client = useMemo<SessionClient>(
89
+ export const VoiceProvider = ({ children }: { children: ReactNode }) => {
90
+ const client = useMemo<HostClient>(
116
91
  () =>
117
- createSessionClient({
92
+ createHostClient({
118
93
  apiUrl: process.env.NEXT_PUBLIC_CALLPAD_API_URL!,
119
- userId: customerId,
94
+ vendor: "acme",
95
+ userId: 42,
120
96
  auth: { getAccessToken },
121
97
  }),
122
- [customerId],
98
+ [],
123
99
  );
124
100
 
125
101
  return (
126
102
  <CallpadProvider client={client} connect disposeOnUnmount>
127
- <CustomerCallButton />
103
+ {children}
128
104
  </CallpadProvider>
129
105
  );
130
106
  };
131
-
132
- const CustomerCallButton = () => {
133
- const callpad = useCallpad();
134
- const [join, setJoin] = useState<VoiceJoinGrant | null>(null);
135
-
136
- const startCall = async () => {
137
- const started = await callpad.sessions.start({
138
- vendor,
139
- target: { type: "vendor_user", userId: vendorUserId },
140
- });
141
- setJoin(started.join);
142
- };
143
-
144
- return (
145
- <>
146
- <button type="button" onClick={startCall}>
147
- Call support
148
- </button>
149
- {join ? <SessionMedia join={join} /> : null}
150
- </>
151
- );
152
- };
153
-
154
- const SessionMedia = ({ join }: { join: VoiceJoinGrant }) => (
155
- <LiveKitRoom serverUrl={join.url} token={join.token} audio video={false}>
156
- <RoomAudioRenderer />
157
- </LiveKitRoom>
158
- );
159
107
  ```
160
108
 
161
- ## Vendor App
109
+ Hooks:
162
110
 
163
- Vendor-side apps use the host client. Host clients can start calls to customers,
164
- contacts, and phone numbers, and can use elevated controls such as recording and
165
- ending a session.
111
+ | Hook | Purpose |
112
+ | ---------------------------- | ------------------------------------------- |
113
+ | `useCallpad()` | Current SDK client. |
114
+ | `useStatus()` | Realtime connection status. |
115
+ | `useSessions()` | Visible session collection. |
116
+ | `useSession(id)` | Exact session lookup. Pass `null` for none. |
117
+ | `useCurrentSession()` | Current browser media-session snapshot. |
118
+ | `useCurrentSessionActions()` | Current session commands and media events. |
119
+ | `useIncomingInvites()` | Pending invites for the current user. |
120
+ | `useIncomingInvite()` | First pending invite shortcut. |
121
+ | `useCan(id, action)` | UI helper for low-level session actions. |
122
+ | `useSessionDuration(id)` | Live elapsed time for an exact session ID. |
123
+
124
+ Starting a session:
166
125
 
167
- ```ts
168
- import { createHostClient } from "farvex";
126
+ ```tsx
127
+ import { useCallpad, useCurrentSession } from "farvex/react";
169
128
 
170
- const callpad = createHostClient({
171
- apiUrl: "https://api.callpad.example.com",
172
- vendor: "acme",
173
- userId: user.id,
174
- auth: { getAccessToken },
175
- });
129
+ const StartButton = () => {
130
+ const client = useCallpad<HostClient>();
131
+ const { busy } = useCurrentSession();
176
132
 
177
- const started = await callpad.sessions.start({
178
- target: { type: "customer", userId: customer.id },
179
- });
133
+ const start = () =>
134
+ client.session.start({
135
+ target: { type: "phone", phone: "+15551234567" },
136
+ });
180
137
 
181
- await callpad.host.startRecording(started.session.id);
138
+ return (
139
+ <button type="button" disabled={busy} onClick={start}>
140
+ Start session
141
+ </button>
142
+ );
143
+ };
182
144
  ```
183
145
 
184
- ### Receiving Customer Calls
185
-
186
- When a customer calls a vendor user, the vendor app receives an incoming invite
187
- over realtime. Accepting the invite returns a join grant for the vendor user.
146
+ Accepting an invite:
188
147
 
189
148
  ```tsx
190
- "use client";
191
-
192
- import { useState } from "react";
193
- import { type VoiceJoinGrant } from "farvex";
194
149
  import { useCallpad, useIncomingInvite } from "farvex/react";
195
- import { LiveKitRoom, RoomAudioRenderer } from "farvex/livekit";
196
150
 
197
- export const IncomingCall = () => {
198
- const callpad = useCallpad();
151
+ const IncomingInvite = () => {
152
+ const client = useCallpad<HostClient>();
199
153
  const invite = useIncomingInvite();
200
- const [join, setJoin] = useState<VoiceJoinGrant | null>(null);
201
154
 
202
155
  if (!invite) {
203
156
  return null;
204
157
  }
205
158
 
206
- const accept = async () => {
207
- const started = await callpad.sessions.accept({
208
- sessionId: invite.session.id,
209
- participantId: invite.participant.id,
210
- });
211
- setJoin(started.join);
212
- };
213
-
214
- const reject = () =>
215
- callpad.sessions.reject({
216
- sessionId: invite.session.id,
217
- participantId: invite.participant.id,
218
- });
219
-
220
159
  return (
221
160
  <>
222
- <button type="button" onClick={accept}>
161
+ <button
162
+ type="button"
163
+ onClick={() =>
164
+ client.session.accept({
165
+ sessionId: invite.session.id,
166
+ participantId: invite.participant.id,
167
+ })
168
+ }
169
+ >
223
170
  Answer
224
171
  </button>
225
- <button type="button" onClick={reject}>
172
+ <button
173
+ type="button"
174
+ onClick={() =>
175
+ client.sessions.reject({
176
+ sessionId: invite.session.id,
177
+ participantId: invite.participant.id,
178
+ })
179
+ }
180
+ >
226
181
  Decline
227
182
  </button>
228
- {join ? (
229
- <LiveKitRoom serverUrl={join.url} token={join.token} audio video={false}>
230
- <RoomAudioRenderer />
231
- </LiveKitRoom>
232
- ) : null}
233
183
  </>
234
184
  );
235
185
  };
236
186
  ```
237
187
 
238
- ### Host Controls
188
+ Overlay and media should use the same current session ID:
239
189
 
240
- ```ts
241
- const sessionId = started.session.id;
190
+ ```tsx
191
+ import { RoomAudioRenderer, SessionRoom } from "farvex/livekit";
192
+ import { useCurrentSession, useCurrentSessionActions, useSession } from "farvex/react";
242
193
 
243
- await callpad.host.addParticipant({
244
- sessionId,
245
- target: { type: "phone", phone: "+15551234567" },
246
- });
194
+ const SessionOverlay = () => {
195
+ const snapshot = useCurrentSession();
196
+ const actions = useCurrentSessionActions();
197
+ const session = useSession(snapshot.current?.sessionId ?? null);
247
198
 
248
- await callpad.host.startRecording(sessionId);
249
- await callpad.host.stopRecording(sessionId);
250
- await callpad.host.end(sessionId);
199
+ return (
200
+ <>
201
+ <SessionRoom session={snapshot.current} controls={actions}>
202
+ <RoomAudioRenderer />
203
+ </SessionRoom>
204
+ {snapshot.current && session ? (
205
+ <SessionPanel session={session} phase={snapshot.current.phase} />
206
+ ) : null}
207
+ </>
208
+ );
209
+ };
251
210
  ```
252
211
 
253
- Use `sessions.can(session, action)` or the React `useCan(id, action)` hook to
254
- gate controls before calling actions such as `accept`, `leave`, `end`, or
255
- `startRecording`.
256
-
257
- ## React
212
+ ## LiveKit
258
213
 
259
- Import `farvex/react` only from client components.
214
+ `farvex/livekit` re-exports first-party LiveKit primitives and adds
215
+ `SessionRoom`. Use `SessionRoom` for the normal SDK flow; use raw `LiveKitRoom`
216
+ only for advanced media ownership.
260
217
 
261
218
  ```tsx
262
- "use client";
263
-
264
- import { useMemo, type ReactNode } from "react";
265
- import { createHostClient, type HostClient } from "farvex";
266
- import {
267
- CallpadProvider,
268
- useCallpad,
269
- useIncomingInvite,
270
- useSession,
271
- useStatus,
272
- } from "farvex/react";
273
-
274
- export const VoiceProvider = ({ children }: { children: ReactNode }) => {
275
- const client = useMemo<HostClient>(
276
- () =>
277
- createHostClient({
278
- apiUrl: process.env.NEXT_PUBLIC_CALLPAD_API_URL!,
279
- vendor: "acme",
280
- userId: 42,
281
- auth: { getAccessToken },
282
- }),
283
- [],
284
- );
285
-
286
- return (
287
- <CallpadProvider client={client} connect disposeOnUnmount>
288
- {children}
289
- </CallpadProvider>
290
- );
291
- };
219
+ <SessionRoom session={current} controls={client.session} audio video={false}>
220
+ <RoomAudioRenderer />
221
+ </SessionRoom>
292
222
  ```
293
223
 
294
- Hooks:
224
+ `SessionRoom` renders nothing without a current session or join grant. It passes
225
+ `session.join.url` and `session.join.token` to LiveKit and reports connected,
226
+ disconnected, and error events back to the session controller when `controls` is
227
+ provided.
295
228
 
296
- | Hook | Purpose |
297
- | ------------------------- | ------------------------------------------------- |
298
- | `useCallpad()` | Current SDK client. |
299
- | `useStatus()` | Realtime connection status. |
300
- | `useSessions()` | Visible live sessions. |
301
- | `useSession(id?)` | Specific session, or active session when omitted. |
302
- | `useIncomingInvites()` | Pending invites for the current user. |
303
- | `useIncomingInvite()` | First pending invite. |
304
- | `useCan(id, action)` | UI helper for session actions. |
305
- | `useSessionDuration(id?)` | Live elapsed time. |
306
-
307
- Session participants and metadata are exposed on `VoiceSession.participants`.
308
- Use LiveKit hooks for media participants, tracks, mute state, speaking state,
309
- device selection, and room lifecycle.
229
+ ## Ringing Timeout
310
230
 
311
- ## LiveKit
231
+ The SDK may hide expired incoming invites locally for immediate UX, but backend
232
+ REST/realtime state is authoritative for final session state. Apps should render
233
+ terminal state from `VoiceSession.state`, realtime events, and REST
234
+ reconciliation rather than treating a local timer as final.
312
235
 
313
- `farvex/livekit` re-exports first-party LiveKit primitives:
236
+ ## Migration Notes
237
+
238
+ Replace no-ID `useSession()`:
314
239
 
315
240
  ```tsx
316
- import { LiveKitRoom, RoomAudioRenderer } from "farvex/livekit";
317
- import type { VoiceJoinGrant } from "farvex";
318
-
319
- export const SessionMedia = ({ join }: { join: VoiceJoinGrant }) => (
320
- <LiveKitRoom serverUrl={join.url} token={join.token} audio video={false}>
321
- <RoomAudioRenderer />
322
- </LiveKitRoom>
323
- );
241
+ const { current } = useCurrentSession();
242
+ const session = useSession(current?.sessionId ?? null);
324
243
  ```
325
244
 
245
+ Replace local raw join-grant state with `client.session` and `SessionRoom`.
246
+ The current session stores `{ sessionId, participantId, join }`, so the overlay,
247
+ media room, controls, and participant UI all target the same session.
248
+
249
+ SDK source should use the exported `Nullable<T>` alias for nullable types instead
250
+ of direct null unions in public SDK types.
251
+
326
252
  ## Development
327
253
 
328
254
  From `websdk/`: