farvex 0.2.0 → 1.0.1

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,15 +1,32 @@
1
1
  # farvex
2
2
 
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix host client disposal, expire stale incoming invites, and export host start input types for frontend integrations.
8
+
9
+ ## 1.0.0
10
+
11
+ ### Major Changes
12
+
13
+ - Prepare the SDK for a stable release with expanded customer and vendor integration documentation.
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  ### Minor Changes
6
18
 
19
+ - **BREAKING**: Session APIs are now vendor-scoped. The SDK uses `/api/v1/vendors/{slug}/sessions...` and no longer calls `/api/v1/sessions`.
20
+ - **BREAKING**: `CallpadConfig` now requires `audience: "agent" | "customer"`. Agent clients fetch vendor Pulse grants; customer clients fetch global user Pulse grants.
21
+ - **BREAKING**: Removed root `client.createSession`. Use `client.sessions.create(...)` for agent-originated sessions and `client.sessions.request(...)` for customer-originated sessions.
22
+ - Added customer session request flow with default auto-join and `useSessionRequest()`.
23
+ - Added `session.updated` client event and React hook invalidation for session upserts.
7
24
  - **BREAKING**: Replaced Offer with Invite. `OfferManager` → `InviteManager`, `Offer` → `Invite`, `useIncomingOffer` → `useIncomingInvite`, pulse topics `session.offer.*` → `session.invite.*`, endpoints `/sessions/offers/{offerId}/*` → `/sessions/{sessionId}/invites/{inviteId}/*`. No deprecation shims — all offer-era symbols are removed.
8
25
  - **BREAKING**: `Session.telephonyLegs` is now derived from `participant.operations[]` where `kind === "sip_leg"`. The backend no longer returns a top-level `telephonyLegs[]`.
9
26
  - **BREAKING**: Participant lifecycle states changed. Removed `ringing`, `joining`. Added `accepted`, `declined`, `withdrawn`. New `SessionEventMap` keys: `participant.accepted`, `participant.declined`, `participant.withdrawn`.
10
27
  - **BREAKING**: `isSelf` is now keyed by `participantId` instead of `participantIdentity`. `SessionManagerOptions.selfIdentity` renamed to `selfParticipantId`. The join grant now carries `participantId`; `Session.join()` uses it.
11
28
  - **BREAKING**: `useCallControls` controls now expose `enabled: boolean` on each slot and gate actions by the current participant's `capabilities[]`. `leave`/`end` return `{enabled, run}` instead of a bare function. Disabled when capability is missing; never hidden.
12
- - Added `Dispatch` surface (ring-group) with `session.dispatches`, `useSessionDispatches`, and events: `dispatch.started`, `dispatch.resolved`, `dispatch.cancelled`, `dispatch.expired`.
29
+ - Added `Dispatch` surface for team fan-out with `session.dispatches` and events: `dispatch.started`, `dispatch.resolved`, `dispatch.cancelled`.
13
30
  - Added `SessionRouteContext` accessible as `session.route`.
14
31
  - Added `useSessionCapabilities(sessionId?)` returning a capability `Set` for self.
15
32
  - Added `session.capabilitiesOf(participantId?)`.
package/README.md CHANGED
@@ -1,230 +1,347 @@
1
1
  # farvex
2
2
 
3
- Web SDK for Callpad sessions a thin, React-friendly wrapper over LiveKit and the Callpad Pulse realtime layer for managing WebRTC and SIP sessions.
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.
7
+
8
+ ## Exports
9
+
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. |
4
15
 
5
16
  ## Install
6
17
 
7
18
  ```sh
8
- npm i farvex
9
- # or
10
- pnpm add farvex
11
- # or
12
- bun add farvex
19
+ npm install farvex livekit-client @livekit/components-react
13
20
  ```
14
21
 
15
- React and react-dom are optional peer dependencies (only needed when you import from `farvex/react`):
22
+ `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.
25
+
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.
41
+
42
+ ```ts
43
+ import { createSessionClient } from "farvex";
44
+
45
+ const callpad = createSessionClient({
46
+ apiUrl: "https://api.callpad.example.com",
47
+ userId: currentUser.id,
48
+ auth: {
49
+ getAccessToken,
50
+ onUnauthorized: () => {
51
+ auth.signOut();
52
+ },
53
+ },
54
+ });
55
+ ```
16
56
 
17
- ```sh
18
- pnpm add react react-dom
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();
19
65
  ```
20
66
 
21
- ## Subpath exports
67
+ SDK methods throw `CallpadError` for API problem responses. They do not return a
68
+ custom result wrapper.
22
69
 
23
- | Entry | Use when |
24
- | ---------------- | -------------------------------------------------------------- |
25
- | `farvex` | Curated top-level exports. |
26
- | `farvex/core` | Framework-agnostic client (browser-safe at module load). |
27
- | `farvex/react` | React hooks and providers. Carries `"use client"` for Next.js. |
28
- | `farvex/livekit` | Re-exports livekit-client + @livekit/components-react types. |
29
- | `farvex/mock` | In-memory mock client for tests and storybook. |
70
+ ## Customer App
30
71
 
31
- ## Quickstart (React)
72
+ Customer apps use the common session client:
73
+
74
+ ```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 },
81
+ });
82
+
83
+ await callpad.connect();
84
+
85
+ const started = await callpad.sessions.start({
86
+ vendor: "acme",
87
+ target: { type: "vendor_user", userId: 42 },
88
+ });
89
+
90
+ if (!started.join) {
91
+ throw new Error("This session did not return a media grant");
92
+ }
93
+ ```
94
+
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.
97
+
98
+ ### Customer Calls A Vendor User
99
+
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.
32
102
 
33
103
  ```tsx
34
- import {
35
- CallpadProvider,
36
- useIncomingInvite,
37
- useCallControls,
38
- } from "farvex/react";
104
+ "use client";
105
+
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;
113
+
114
+ export const CustomerVoice = ({ customerId }: { customerId: number }) => {
115
+ const client = useMemo<SessionClient>(
116
+ () =>
117
+ createSessionClient({
118
+ apiUrl: process.env.NEXT_PUBLIC_CALLPAD_API_URL!,
119
+ userId: customerId,
120
+ auth: { getAccessToken },
121
+ }),
122
+ [customerId],
123
+ );
39
124
 
40
- export default function App() {
41
125
  return (
42
- <CallpadProvider
43
- config={{
44
- apiBaseUrl: "https://api.callpad.example.com",
45
- vendor: "acme",
46
- auth: { getAccessToken: async () => myToken() },
47
- }}
48
- >
49
- <CallScreen />
126
+ <CallpadProvider client={client} connect disposeOnUnmount>
127
+ <CustomerCallButton />
50
128
  </CallpadProvider>
51
129
  );
52
- }
130
+ };
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
+ ```
160
+
161
+ ## Vendor App
162
+
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.
166
+
167
+ ```ts
168
+ import { createHostClient } from "farvex";
169
+
170
+ const callpad = createHostClient({
171
+ apiUrl: "https://api.callpad.example.com",
172
+ vendor: "acme",
173
+ userId: user.id,
174
+ auth: { getAccessToken },
175
+ });
176
+
177
+ const started = await callpad.sessions.start({
178
+ target: { type: "customer", userId: customer.id },
179
+ });
180
+
181
+ await callpad.host.startRecording(started.session.id);
182
+ ```
183
+
184
+ ### Receiving Customer Calls
53
185
 
54
- function CallScreen() {
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.
188
+
189
+ ```tsx
190
+ "use client";
191
+
192
+ import { useState } from "react";
193
+ import { type VoiceJoinGrant } from "farvex";
194
+ import { useCallpad, useIncomingInvite } from "farvex/react";
195
+ import { LiveKitRoom, RoomAudioRenderer } from "farvex/livekit";
196
+
197
+ export const IncomingCall = () => {
198
+ const callpad = useCallpad();
55
199
  const invite = useIncomingInvite();
56
- const controls = useCallControls();
57
-
58
- if (invite) {
59
- return (
60
- <div>
61
- <p>Incoming invite from {invite.inviterParticipantId ?? "unknown"}</p>
62
- <button onClick={() => invite.accept()}>Accept</button>
63
- <button onClick={() => invite.reject()}>Decline</button>
64
- </div>
65
- );
200
+ const [join, setJoin] = useState<VoiceJoinGrant | null>(null);
201
+
202
+ if (!invite) {
203
+ return null;
66
204
  }
67
205
 
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
+
68
220
  return (
69
- <div>
70
- <button disabled={!controls.hold.enabled} onClick={controls.hold.toggle}>
71
- {controls.hold.active ? "Resume" : "Hold"}
72
- </button>
73
- <button
74
- disabled={!controls.recording.enabled}
75
- onClick={controls.recording.toggle}
76
- >
77
- {controls.recording.active ? "Stop recording" : "Record"}
221
+ <>
222
+ <button type="button" onClick={accept}>
223
+ Answer
78
224
  </button>
79
- <button disabled={!controls.end.enabled} onClick={controls.end.run}>
80
- End
225
+ <button type="button" onClick={reject}>
226
+ Decline
81
227
  </button>
82
- </div>
228
+ {join ? (
229
+ <LiveKitRoom serverUrl={join.url} token={join.token} audio video={false}>
230
+ <RoomAudioRenderer />
231
+ </LiveKitRoom>
232
+ ) : null}
233
+ </>
83
234
  );
84
- }
235
+ };
85
236
  ```
86
237
 
87
- Controls are capability-gated: the backend sends a per-participant `capabilities[]` list, and hooks return `enabled: false` when the required capability is missing. Render them disabled rather than hidden.
88
-
89
- ## Core concepts
90
-
91
- - **Session** — one call. Has `participants`, `dispatches`, `recordings`, `route`. State is derived: `ringing | active | on_hold | ended`. Telephony legs (SIP) live inside each participant's `operations[]`.
92
- - **Invite** — a pending app invite sent to a specific recipient (agent or user). The recipient accepts via `POST /api/v1/sessions/{sessionId}/invites/{inviteId}/accept`.
93
- - **Dispatch** — a ring-group: one or more agents rung in parallel or sequentially. When one answers, the dispatch `resolves` and the siblings transition to `withdrawn`.
94
- - **Capability** — per-participant permission: `hold_session`, `end_session`, `start_recording`, etc. Use `session.capabilitiesOf()` to gate UI.
95
-
96
- ## React hooks
97
-
98
- | Hook | Purpose |
99
- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
100
- | `useCallpadClient()` | Access underlying client + connection status. |
101
- | `useSessions()` / `useSession(id?)` | All sessions / active session or by id. |
102
- | `useIncomingInvite()` / `useIncomingInvites()` | First pending invite / all pending invites for the current user. |
103
- | `useSessionParticipants(id?)` / `useSessionParticipant(pid, id?)` / `useSelfParticipant(id?)` | Participants in a session. |
104
- | `useSessionCapabilities(id?)` | Capability `Set` for self in the active session. |
105
- | `useSessionDispatches(id?)` | Active ring-group dispatches in a session. |
106
- | `useCallControls(id?)` | Capability-gated mic / camera / screen / hold / recording / leave / end. |
107
- | `useCallDuration(id?)` | `{formatted, elapsedSeconds}` since session activated. |
108
- | `useIsHeld(id?)` | Whether the session is on hold. |
109
- | `useActiveRecording(id?)` | Current in-progress recording (if any). |
110
- | `useSessionEvent(event, handler, id?)` | Subscribe to a session event with auto cleanup. |
111
- | `useClientEvent(event, handler)` | Subscribe to a client event with auto cleanup. |
112
- | `usePresence(refs)` / `useMyPresence()` | Presence query + my presence control. |
113
-
114
- ## Realtime events
115
-
116
- Pulse pushes these topics (SDK forwards them on `PulseTransport`):
117
-
118
- | Topic | Payload |
119
- | ----------------------- | ------------------------------------------------------------------------ |
120
- | `session.upsert` | Full `SessionView`. |
121
- | `session.remove` | `{sessionId, version}`. Also drops any pending invites for that session. |
122
- | `session.invite.upsert` | `SessionInviteView` — add/update a pending invite for the current user. |
123
- | `session.invite.remove` | `{inviteId, sessionId, participantId, state}`. |
124
-
125
- ## HTTP endpoints used
126
-
127
- All under `/api/v1`:
128
-
129
- - `GET /sessions` — prime the session list.
130
- - `POST /sessions` — create a session.
131
- - `GET /sessions/{sessionId}` — fetch by id.
132
- - `PATCH /sessions/{sessionId}` — update name.
133
- - `POST /sessions/{sessionId}/join` — get a LiveKit token grant.
134
- - `POST /sessions/{sessionId}/hold` / `.../unhold` / `.../cancel` / `.../leave` / `.../end`
135
- - `POST /sessions/{sessionId}/participants` — invite a target (agent/user/contact/phone).
136
- - `DELETE /sessions/{sessionId}/participants/{participantId}` — remove a participant.
137
- - `POST /sessions/{sessionId}/recordings` / `.../recordings/{recordingId}/stop` — recording control.
138
- - `GET /sessions/invites` — list pending invites for the current user.
139
- - `POST /sessions/{sessionId}/invites/{inviteId}/accept` / `.../reject` — act on an invite.
140
-
141
- ## Local development
238
+ ### Host Controls
142
239
 
143
- From `websdk/`:
240
+ ```ts
241
+ const sessionId = started.session.id;
144
242
 
145
- ```sh
146
- bun install
147
- bun run dev # tsup watch mode; rebuilds dist/ on change
148
- bun run test # vitest; includes unit tests next to source files
149
- bun run check # lint + format + typecheck
150
- bun run build # one-off production build
243
+ await callpad.host.addParticipant({
244
+ sessionId,
245
+ target: { type: "phone", phone: "+15551234567" },
246
+ });
247
+
248
+ await callpad.host.startRecording(sessionId);
249
+ await callpad.host.stopRecording(sessionId);
250
+ await callpad.host.end(sessionId);
151
251
  ```
152
252
 
153
- API types are generated from the backend's OpenAPI spec:
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`.
154
256
 
155
- ```sh
156
- # start the backend first: `bun run docker:up && docker compose up callpad.http`
157
- bun run generate:api # writes src/api/generated/
158
- ```
257
+ ## React
159
258
 
160
- ### Linking into a consumer app (goagentfe, customer app, etc.)
259
+ Import `farvex/react` only from client components.
161
260
 
162
- We recommend [`yalc`](https://github.com/wclr/yalc) over `pnpm link` / `bun link` because it survives Vite and Next.js module caches.
261
+ ```tsx
262
+ "use client";
163
263
 
164
- One-time setup:
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";
165
273
 
166
- ```sh
167
- # inside websdk/
168
- bun run dev # keep running; watches src/ and rebuilds dist/
169
- npx yalc publish --push # in another shell, first time
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
+ };
170
292
  ```
171
293
 
172
- In the consumer app (e.g. `goagentfe`):
294
+ Hooks:
173
295
 
174
- ```sh
175
- npx yalc add farvex
176
- pnpm install
177
- ```
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. |
178
306
 
179
- Each change in `websdk/`:
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.
180
310
 
181
- ```sh
182
- # inside websdk/
183
- npx yalc push # push built dist/ to subscribers
184
- ```
311
+ ## LiveKit
185
312
 
186
- Before committing in the consumer app, undo the local link:
313
+ `farvex/livekit` re-exports first-party LiveKit primitives:
187
314
 
188
- ```sh
189
- npx yalc remove --all
190
- pnpm install
315
+ ```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
+ );
191
324
  ```
192
325
 
193
- ## Release flow
326
+ ## Development
327
+
328
+ From `websdk/`:
194
329
 
195
330
  ```sh
196
- bun run changeset # while working on a change
197
- bun run version # applies pending changesets, writes CHANGELOG.md
198
- git commit -am "chore: release farvex@x.y.z"
199
- bun run release # builds, runs changeset publish
200
- git push --follow-tags
331
+ bun run generate
332
+ bun run check
333
+ bun run build
201
334
  ```
202
335
 
203
- Pre-1.0: every feature ships as a `minor` bump; breaking changes allowed without a major.
204
-
205
- ## Package conventions
336
+ HTTP calls must go through the generated Hey API client in `src/api/generated/`.
337
+ Do not hand-edit generated files.
206
338
 
207
- - Side-effect free — `"sideEffects": false` in `package.json`. Tree-shakes cleanly.
208
- - No top-level access to `window`, `document`, `navigator`, `RTCPeerConnection`, or `localStorage`. Any such access happens inside methods or effects.
209
- - No `any` or `unknown` in exported types.
210
- - Tests live next to source (`foo.test.ts` beside `foo.ts`).
211
- - `engines.node >= 18`.
339
+ ## Release
212
340
 
213
- ## Layout
341
+ Changesets lives in `websdk/.changeset`. From `websdk/`:
214
342
 
215
- ```
216
- websdk/
217
- src/
218
- index.ts public entry
219
- core/ framework-agnostic client
220
- session/ Session class + commands
221
- managers/ SessionManager, InviteManager, PresenceManager
222
- transport/ PulseTransport, MediaRoomAdapter
223
- react/ React hooks + providers
224
- mock/ in-memory mock client + scenarios
225
- test/ setup + cross-cutting smoke tests
226
- dist/ build output (gitignored)
227
- tsup.config.ts build config (ESM + CJS + dts, use-client preserved)
228
- tsconfig.json strict TS, bundler resolution
229
- vitest.config.ts jsdom test env (includes src/**/*.test.ts)
343
+ ```sh
344
+ bun run changeset
345
+ bun run version
346
+ bun run release
230
347
  ```