@callembed/sdk 0.0.0-stage → 0.1.2

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/dist/callembed.iife.js +129 -0
  4. package/dist/core/agent.d.ts +10 -0
  5. package/dist/core/agent.js +19 -0
  6. package/dist/core/api.d.ts +44 -0
  7. package/dist/core/api.js +169 -0
  8. package/dist/core/audio.d.ts +20 -0
  9. package/dist/core/audio.js +68 -0
  10. package/dist/core/call.d.ts +45 -0
  11. package/dist/core/call.js +252 -0
  12. package/dist/core/client.d.ts +53 -0
  13. package/dist/core/client.js +174 -0
  14. package/dist/core/errors.d.ts +22 -0
  15. package/dist/core/errors.js +41 -0
  16. package/dist/core/events.d.ts +20 -0
  17. package/dist/core/events.js +43 -0
  18. package/dist/core/hosted-embed.d.ts +14 -0
  19. package/dist/core/hosted-embed.js +14 -0
  20. package/dist/core/metadata.d.ts +13 -0
  21. package/dist/core/metadata.js +38 -0
  22. package/dist/core/ring-watch.d.ts +20 -0
  23. package/dist/core/ring-watch.js +46 -0
  24. package/dist/core/room-events.d.ts +13 -0
  25. package/dist/core/room-events.js +29 -0
  26. package/dist/core/state-machine.d.ts +11 -0
  27. package/dist/core/state-machine.js +44 -0
  28. package/dist/core/teardown.d.ts +49 -0
  29. package/dist/core/teardown.js +107 -0
  30. package/dist/core/types.d.ts +184 -0
  31. package/dist/core/types.js +5 -0
  32. package/dist/iife.d.ts +23 -0
  33. package/dist/iife.js +27 -0
  34. package/dist/index.d.ts +12 -0
  35. package/dist/index.js +11 -0
  36. package/dist/ui/bind.d.ts +14 -0
  37. package/dist/ui/bind.js +82 -0
  38. package/dist/ui/button.d.ts +28 -0
  39. package/dist/ui/button.js +57 -0
  40. package/dist/ui/copy-en.d.ts +3 -0
  41. package/dist/ui/copy-en.js +25 -0
  42. package/dist/ui/copy-zh-tw.d.ts +28 -0
  43. package/dist/ui/copy-zh-tw.js +25 -0
  44. package/dist/ui/copy.d.ts +18 -0
  45. package/dist/ui/copy.js +45 -0
  46. package/dist/ui/index.d.ts +12 -0
  47. package/dist/ui/index.js +12 -0
  48. package/dist/ui/panel.d.ts +26 -0
  49. package/dist/ui/panel.js +62 -0
  50. package/dist/ui/theme.d.ts +13 -0
  51. package/dist/ui/theme.js +124 -0
  52. package/package.json +76 -4
@@ -0,0 +1,10 @@
1
+ import type { RemoteParticipant } from "livekit-client";
2
+ /**
3
+ * Detect a server-minted agent participant by identity prefix only.
4
+ *
5
+ * CallEmbed `mintToken` identities are `${role}_${…}`. Display names and
6
+ * participant metadata are never trusted (users cannot update metadata).
7
+ */
8
+ export declare function isAgentParticipant(participant: RemoteParticipant): boolean;
9
+ /** True when the agent participant already has a subscribed/published audio track. */
10
+ export declare function agentHasAudio(participant: RemoteParticipant): boolean;
@@ -0,0 +1,19 @@
1
+ import { Track } from "livekit-client";
2
+ /**
3
+ * Detect a server-minted agent participant by identity prefix only.
4
+ *
5
+ * CallEmbed `mintToken` identities are `${role}_${…}`. Display names and
6
+ * participant metadata are never trusted (users cannot update metadata).
7
+ */
8
+ export function isAgentParticipant(participant) {
9
+ return (participant.identity || "").startsWith("agent_");
10
+ }
11
+ /** True when the agent participant already has a subscribed/published audio track. */
12
+ export function agentHasAudio(participant) {
13
+ for (const pub of participant.trackPublications.values()) {
14
+ if (pub.kind === Track.Kind.Audio && (pub.track || pub.isSubscribed)) {
15
+ return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
@@ -0,0 +1,44 @@
1
+ import type { CallSession, RuntimeConfig, StartCallOptions } from "./types.js";
2
+ /**
3
+ * Create a call via `POST /v1/calls` using the publishable key only.
4
+ * Response includes `endToken` (memory-only — never persist or log).
5
+ */
6
+ export declare function apiCreateCall(cfg: RuntimeConfig, opts: StartCallOptions): Promise<CallSession>;
7
+ /**
8
+ * Add random jitter on top of a base delay without exceeding `maxMs`.
9
+ * Base is always honored as a floor (Retry-After / exponential step).
10
+ * @internal exported for tests
11
+ */
12
+ export declare function withJitter(baseMs: number, maxMs: number, random?: () => number): number;
13
+ /**
14
+ * Delay before the next retry. Honors Retry-After when present (capped at 30s),
15
+ * otherwise exponential backoff from attempt index (capped at 8s). Always applies
16
+ * a small random jitter without dropping below the computed base.
17
+ * @internal exported for tests
18
+ */
19
+ export declare function retryDelayMs(res: {
20
+ headers: {
21
+ get(name: string): string | null;
22
+ };
23
+ } | null, attempt: number, random?: () => number): number;
24
+ /**
25
+ * Best-effort `POST /v1/calls/:id/end` with the publishable key + endToken.
26
+ * Retries on 429 (honors Retry-After + jitter) and transient network errors so a
27
+ * brief rate-limit spike does not leave the call open.
28
+ */
29
+ export declare function apiEndCall(cfg: RuntimeConfig, callId: string, endToken?: string | null): Promise<void>;
30
+ /**
31
+ * End-call during `pagehide` / unload using `keepalive`.
32
+ * endToken is only in the JSON body — never in the URL.
33
+ * Single attempt only (page is unloading; delayed retries are unreliable).
34
+ */
35
+ export declare function apiEndCallKeepalive(cfg: RuntimeConfig, callId: string, endToken?: string | null): void;
36
+ /**
37
+ * Project-scoped call status (detect ring-timeout `no_answer`).
38
+ * Caller must send `endToken` via `X-Call-End-Token` (never in the URL).
39
+ * Retries briefly on 429 (Retry-After + jitter) so ring polls survive key/IP spikes.
40
+ */
41
+ export declare function apiGetCallStatus(cfg: RuntimeConfig, callId: string, endToken?: string | null): Promise<{
42
+ status: string;
43
+ failReason: string | null;
44
+ } | null>;
@@ -0,0 +1,169 @@
1
+ import { CallEmbedError } from "./errors.js";
2
+ import { assertValidMetadata } from "./metadata.js";
3
+ /** Publishable request headers; includes parent origin for hosted iframe (B109). */
4
+ function publishableHeaders(cfg, extra) {
5
+ const headers = {
6
+ "X-Publishable-Key": cfg.publishableKey,
7
+ ...extra,
8
+ };
9
+ if (cfg.parentOrigin) {
10
+ headers["X-CallEmbed-Parent-Origin"] = cfg.parentOrigin;
11
+ }
12
+ return headers;
13
+ }
14
+ /**
15
+ * Create a call via `POST /v1/calls` using the publishable key only.
16
+ * Response includes `endToken` (memory-only — never persist or log).
17
+ */
18
+ export async function apiCreateCall(cfg, opts) {
19
+ assertValidMetadata(opts.metadata);
20
+ const base = cfg.apiBaseUrl.replace(/\/$/, "");
21
+ const res = await fetch(`${base}/v1/calls`, {
22
+ method: "POST",
23
+ headers: publishableHeaders(cfg, { "Content-Type": "application/json" }),
24
+ body: JSON.stringify({
25
+ queue: opts.queue,
26
+ ...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
27
+ ...(opts.displayName !== undefined ? { displayName: opts.displayName } : {}),
28
+ }),
29
+ });
30
+ let data;
31
+ try {
32
+ data = (await res.json());
33
+ }
34
+ catch {
35
+ throw new CallEmbedError("connection_failed", `HTTP ${res.status}: invalid JSON response`, res.status);
36
+ }
37
+ if (!res.ok) {
38
+ throw new CallEmbedError(data.error || "call_failed", data.message || `HTTP ${res.status}`, res.status);
39
+ }
40
+ if (!data.callId || !data.token || !data.livekitUrl) {
41
+ throw new CallEmbedError("call_failed", "API response missing call session fields.", res.status);
42
+ }
43
+ return data;
44
+ }
45
+ function endCallUrl(cfg, callId) {
46
+ const base = cfg.apiBaseUrl.replace(/\/$/, "");
47
+ return `${base}/v1/calls/${encodeURIComponent(callId)}/end`;
48
+ }
49
+ function callStatusUrl(cfg, callId) {
50
+ const base = cfg.apiBaseUrl.replace(/\/$/, "");
51
+ return `${base}/v1/calls/${encodeURIComponent(callId)}`;
52
+ }
53
+ function endBody(endToken) {
54
+ if (endToken)
55
+ return JSON.stringify({ endToken });
56
+ return "{}";
57
+ }
58
+ function sleep(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+ const RETRY_AFTER_MAX_MS = 30_000;
62
+ const EXP_BACKOFF_MAX_MS = 8_000;
63
+ /**
64
+ * Add random jitter on top of a base delay without exceeding `maxMs`.
65
+ * Base is always honored as a floor (Retry-After / exponential step).
66
+ * @internal exported for tests
67
+ */
68
+ export function withJitter(baseMs, maxMs, random = Math.random) {
69
+ const floor = Math.max(0, Math.floor(baseMs));
70
+ const cap = Math.max(floor, Math.floor(maxMs));
71
+ const spread = Math.min(250, Math.floor(floor * 0.25));
72
+ const jitter = Math.floor(random() * (spread + 1));
73
+ return Math.min(floor + jitter, cap);
74
+ }
75
+ /**
76
+ * Delay before the next retry. Honors Retry-After when present (capped at 30s),
77
+ * otherwise exponential backoff from attempt index (capped at 8s). Always applies
78
+ * a small random jitter without dropping below the computed base.
79
+ * @internal exported for tests
80
+ */
81
+ export function retryDelayMs(res, attempt, random = Math.random) {
82
+ if (res) {
83
+ const raw = res.headers.get("Retry-After");
84
+ const sec = raw ? Number.parseInt(raw, 10) : NaN;
85
+ if (Number.isFinite(sec) && sec > 0) {
86
+ const base = Math.min(Math.max(sec, 1), 30) * 1000;
87
+ return withJitter(base, RETRY_AFTER_MAX_MS, random);
88
+ }
89
+ }
90
+ const base = Math.min(250 * 2 ** attempt, EXP_BACKOFF_MAX_MS);
91
+ return withJitter(base, EXP_BACKOFF_MAX_MS, random);
92
+ }
93
+ const END_MAX_ATTEMPTS = 4;
94
+ const STATUS_MAX_ATTEMPTS = 3;
95
+ /**
96
+ * Best-effort `POST /v1/calls/:id/end` with the publishable key + endToken.
97
+ * Retries on 429 (honors Retry-After + jitter) and transient network errors so a
98
+ * brief rate-limit spike does not leave the call open.
99
+ */
100
+ export async function apiEndCall(cfg, callId, endToken) {
101
+ const url = endCallUrl(cfg, callId);
102
+ const body = endBody(endToken);
103
+ const headers = publishableHeaders(cfg, { "Content-Type": "application/json" });
104
+ for (let attempt = 0; attempt < END_MAX_ATTEMPTS; attempt++) {
105
+ try {
106
+ const res = await fetch(url, { method: "POST", headers, body });
107
+ if (res.status === 429 && attempt < END_MAX_ATTEMPTS - 1) {
108
+ await sleep(retryDelayMs(res, attempt));
109
+ continue;
110
+ }
111
+ return;
112
+ }
113
+ catch {
114
+ if (attempt < END_MAX_ATTEMPTS - 1) {
115
+ await sleep(retryDelayMs(null, attempt));
116
+ continue;
117
+ }
118
+ // Best-effort — final failure is swallowed (hangUp must still resolve).
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * End-call during `pagehide` / unload using `keepalive`.
124
+ * endToken is only in the JSON body — never in the URL.
125
+ * Single attempt only (page is unloading; delayed retries are unreliable).
126
+ */
127
+ export function apiEndCallKeepalive(cfg, callId, endToken) {
128
+ try {
129
+ void fetch(endCallUrl(cfg, callId), {
130
+ method: "POST",
131
+ headers: publishableHeaders(cfg, { "Content-Type": "application/json" }),
132
+ body: endBody(endToken),
133
+ keepalive: true,
134
+ });
135
+ }
136
+ catch {
137
+ // Best-effort
138
+ }
139
+ }
140
+ /**
141
+ * Project-scoped call status (detect ring-timeout `no_answer`).
142
+ * Caller must send `endToken` via `X-Call-End-Token` (never in the URL).
143
+ * Retries briefly on 429 (Retry-After + jitter) so ring polls survive key/IP spikes.
144
+ */
145
+ export async function apiGetCallStatus(cfg, callId, endToken) {
146
+ const headers = publishableHeaders(cfg);
147
+ if (endToken)
148
+ headers["X-Call-End-Token"] = endToken;
149
+ const url = callStatusUrl(cfg, callId);
150
+ for (let attempt = 0; attempt < STATUS_MAX_ATTEMPTS; attempt++) {
151
+ try {
152
+ const res = await fetch(url, { method: "GET", headers });
153
+ if (res.status === 429 && attempt < STATUS_MAX_ATTEMPTS - 1) {
154
+ await sleep(retryDelayMs(res, attempt));
155
+ continue;
156
+ }
157
+ if (!res.ok)
158
+ return null;
159
+ const data = (await res.json());
160
+ if (!data.status)
161
+ return null;
162
+ return { status: data.status, failReason: data.failReason ?? null };
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ return null;
169
+ }
@@ -0,0 +1,20 @@
1
+ import type { RemoteTrack } from "livekit-client";
2
+ /**
3
+ * Hidden DOM host for remote `<audio>` elements.
4
+ * This is **not** a call panel — headless mode still needs a place to attach
5
+ * subscribed tracks so the caller can hear the agent.
6
+ */
7
+ export declare class RemoteAudioHost {
8
+ private host;
9
+ /** Ensure the hidden host exists under `document.body`. */
10
+ ensure(): HTMLElement;
11
+ /**
12
+ * Attach a remote audio track for playback.
13
+ * @param identity - Participant identity (for debugging only; not shown).
14
+ */
15
+ attach(track: RemoteTrack, identity: string): HTMLMediaElement;
16
+ /** Detach media elements for a track and remove them from the host. */
17
+ detachTrack(track: RemoteTrack): void;
18
+ /** Remove all attached elements and the host node. */
19
+ destroy(): void;
20
+ }
@@ -0,0 +1,68 @@
1
+ const HOST_ATTR = "data-ce-audio-host";
2
+ /**
3
+ * Hidden DOM host for remote `<audio>` elements.
4
+ * This is **not** a call panel — headless mode still needs a place to attach
5
+ * subscribed tracks so the caller can hear the agent.
6
+ */
7
+ export class RemoteAudioHost {
8
+ host = null;
9
+ /** Ensure the hidden host exists under `document.body`. */
10
+ ensure() {
11
+ if (typeof document === "undefined") {
12
+ throw new Error("RemoteAudioHost requires a DOM document.");
13
+ }
14
+ if (this.host && this.host.isConnected)
15
+ return this.host;
16
+ const existing = document.querySelector(`[${HOST_ATTR}]`);
17
+ if (existing) {
18
+ this.host = existing;
19
+ return existing;
20
+ }
21
+ const el = document.createElement("div");
22
+ el.setAttribute(HOST_ATTR, "true");
23
+ el.setAttribute("aria-hidden", "true");
24
+ el.style.cssText =
25
+ "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;";
26
+ document.body.appendChild(el);
27
+ this.host = el;
28
+ return el;
29
+ }
30
+ /**
31
+ * Attach a remote audio track for playback.
32
+ * @param identity - Participant identity (for debugging only; not shown).
33
+ */
34
+ attach(track, identity) {
35
+ const host = this.ensure();
36
+ const el = track.attach();
37
+ el.dataset.identity = identity;
38
+ el.autoplay = true;
39
+ el.setAttribute("playsinline", "true");
40
+ host.appendChild(el);
41
+ try {
42
+ const playResult = el.play();
43
+ if (playResult && typeof playResult.catch === "function") {
44
+ void playResult.catch(() => {
45
+ // Autoplay may be blocked until a user gesture; track stays attached.
46
+ });
47
+ }
48
+ }
49
+ catch {
50
+ // jsdom / restricted environments may throw on play()
51
+ }
52
+ return el;
53
+ }
54
+ /** Detach media elements for a track and remove them from the host. */
55
+ detachTrack(track) {
56
+ for (const el of track.detach()) {
57
+ el.remove();
58
+ }
59
+ }
60
+ /** Remove all attached elements and the host node. */
61
+ destroy() {
62
+ if (this.host) {
63
+ this.host.replaceChildren();
64
+ this.host.remove();
65
+ this.host = null;
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,45 @@
1
+ import { Room } from "livekit-client";
2
+ import type { ActiveCall, CallEventHandler, CallEventName, CallStatus, RuntimeConfig, StartCallOptions } from "./types.js";
3
+ /** Optional hooks so the UI layer can mirror status without core importing UI. */
4
+ export interface CallUiHooks {
5
+ onStatus?: (status: CallStatus, message?: string) => void;
6
+ onMuteChange?: (muted: boolean) => void;
7
+ onTerminal?: () => void;
8
+ }
9
+ /** @internal Test seam — replace LiveKit Room construction. */
10
+ export type RoomFactory = () => Room;
11
+ /** @internal */
12
+ export declare function setRoomFactoryForTests(factory: RoomFactory | null): void;
13
+ /** One in-flight call. LiveKit JWT + endToken stay in memory only. */
14
+ export declare class CallSessionController implements ActiveCall {
15
+ callId: string;
16
+ status: CallStatus;
17
+ private room;
18
+ private muted;
19
+ private readonly cfg;
20
+ private readonly emitter;
21
+ private readonly audio;
22
+ private ui;
23
+ private pageHideHandler;
24
+ private hangUpStarted;
25
+ private endCallSent;
26
+ private endToken;
27
+ private readonly ringWatch;
28
+ constructor(cfg: RuntimeConfig, ui?: CallUiHooks);
29
+ setUiHooks(ui: CallUiHooks): void;
30
+ on<E extends CallEventName>(event: E, handler: CallEventHandler<E>): () => void;
31
+ off<E extends CallEventName>(event: E, handler: CallEventHandler<E>): void;
32
+ isMuted(): boolean;
33
+ /** Drive idle → connecting → ringing → (answered). */
34
+ start(opts: StartCallOptions): Promise<void>;
35
+ setMuted(muted: boolean): Promise<void>;
36
+ hangUp(): Promise<void>;
37
+ private asTeardownHost;
38
+ private setStatus;
39
+ private fail;
40
+ private onUnexpectedDisconnect;
41
+ private failNoAnswer;
42
+ private observeParticipant;
43
+ private maybeAnswer;
44
+ private hasAttachedAgentAudio;
45
+ }
@@ -0,0 +1,252 @@
1
+ import { Room, Track, } from "livekit-client";
2
+ import { apiCreateCall } from "./api.js";
3
+ import { RemoteAudioHost } from "./audio.js";
4
+ import { agentHasAudio, isAgentParticipant } from "./agent.js";
5
+ import { CallEmbedError, mapMediaError } from "./errors.js";
6
+ import { TypedEmitter } from "./events.js";
7
+ import { bindRoomEvents } from "./room-events.js";
8
+ import { noAnswerError, RingWatch } from "./ring-watch.js";
9
+ import { isTerminal, transition } from "./state-machine.js";
10
+ import { bindPageHide, cleanupSession, finishAfterHangUp, hangUpSession, teardownMedia, } from "./teardown.js";
11
+ let roomFactory = null;
12
+ /** @internal */
13
+ export function setRoomFactoryForTests(factory) {
14
+ roomFactory = factory;
15
+ }
16
+ function createRoom() {
17
+ if (roomFactory)
18
+ return roomFactory();
19
+ return new Room({
20
+ adaptiveStream: true,
21
+ dynacast: true,
22
+ audioCaptureDefaults: {
23
+ autoGainControl: true,
24
+ echoCancellation: true,
25
+ noiseSuppression: true,
26
+ },
27
+ });
28
+ }
29
+ /** One in-flight call. LiveKit JWT + endToken stay in memory only. */
30
+ export class CallSessionController {
31
+ callId;
32
+ status = "idle";
33
+ room = null;
34
+ muted = false;
35
+ cfg;
36
+ emitter = new TypedEmitter();
37
+ audio = new RemoteAudioHost();
38
+ ui;
39
+ pageHideHandler = null;
40
+ hangUpStarted = false;
41
+ endCallSent = false;
42
+ endToken = null;
43
+ ringWatch;
44
+ constructor(cfg, ui = {}) {
45
+ this.cfg = cfg;
46
+ this.ui = ui;
47
+ this.callId = "pending";
48
+ this.ringWatch = new RingWatch({
49
+ cfg: this.cfg,
50
+ getCallId: () => this.callId,
51
+ getEndToken: () => this.endToken,
52
+ isRinging: () => this.status === "ringing",
53
+ isHangUpStarted: () => this.hangUpStarted,
54
+ onNoAnswer: () => this.failNoAnswer(),
55
+ });
56
+ }
57
+ setUiHooks(ui) {
58
+ this.ui = ui;
59
+ }
60
+ on(event, handler) {
61
+ return this.emitter.on(event, handler);
62
+ }
63
+ off(event, handler) {
64
+ this.emitter.off(event, handler);
65
+ }
66
+ isMuted() {
67
+ return this.muted;
68
+ }
69
+ /** Drive idle → connecting → ringing → (answered). */
70
+ async start(opts) {
71
+ this.setStatus("connecting");
72
+ this.ui.onStatus?.("connecting");
73
+ bindPageHide(this.asTeardownHost());
74
+ let session;
75
+ try {
76
+ session = await apiCreateCall(this.cfg, opts);
77
+ }
78
+ catch (err) {
79
+ if (this.hangUpStarted) {
80
+ await finishAfterHangUp(this.asTeardownHost());
81
+ return;
82
+ }
83
+ const mapped = err instanceof CallEmbedError
84
+ ? err
85
+ : new CallEmbedError("connection_failed", err instanceof Error ? err.message : String(err));
86
+ this.fail(mapped);
87
+ throw mapped;
88
+ }
89
+ this.callId = session.callId;
90
+ this.endToken = typeof session.endToken === "string" ? session.endToken : null;
91
+ if (await finishAfterHangUp(this.asTeardownHost()))
92
+ return;
93
+ const room = createRoom();
94
+ this.room = room;
95
+ bindRoomEvents(room, this.audio, {
96
+ onAgentAudio: (p) => this.maybeAnswer(p),
97
+ onAgentPresent: (p) => this.observeParticipant(p),
98
+ onAgentLeft: () => {
99
+ void this.hangUp();
100
+ },
101
+ onDisconnected: () => {
102
+ if (!isTerminal(this.status) && !this.hangUpStarted) {
103
+ void this.onUnexpectedDisconnect();
104
+ }
105
+ },
106
+ });
107
+ try {
108
+ await room.connect(session.livekitUrl, session.token);
109
+ if (await finishAfterHangUp(this.asTeardownHost()))
110
+ return;
111
+ await room.localParticipant.setMicrophoneEnabled(!this.muted);
112
+ if (await finishAfterHangUp(this.asTeardownHost()))
113
+ return;
114
+ }
115
+ catch (err) {
116
+ if (this.hangUpStarted) {
117
+ await finishAfterHangUp(this.asTeardownHost());
118
+ return;
119
+ }
120
+ const mapped = mapMediaError(err);
121
+ await teardownMedia(this.asTeardownHost());
122
+ this.fail(mapped);
123
+ throw mapped;
124
+ }
125
+ this.setStatus("ringing");
126
+ this.emitter.emit("ringing", { callId: this.callId });
127
+ this.ui.onStatus?.("ringing");
128
+ this.ringWatch.start();
129
+ for (const participant of room.remoteParticipants.values()) {
130
+ this.observeParticipant(participant);
131
+ }
132
+ }
133
+ async setMuted(muted) {
134
+ this.muted = muted;
135
+ if (this.room)
136
+ await this.room.localParticipant.setMicrophoneEnabled(!muted);
137
+ this.emitter.emit("muteChange", { muted });
138
+ this.ui.onMuteChange?.(muted);
139
+ }
140
+ async hangUp() {
141
+ this.ringWatch.stop();
142
+ await hangUpSession(this.asTeardownHost());
143
+ }
144
+ asTeardownHost() {
145
+ return {
146
+ cfg: this.cfg,
147
+ emitter: this.emitter,
148
+ audio: this.audio,
149
+ ui: this.ui,
150
+ getCallId: () => this.callId,
151
+ getStatus: () => this.status,
152
+ setStatusRaw: (s) => {
153
+ this.status = s;
154
+ },
155
+ getRoom: () => this.room,
156
+ setRoom: (r) => {
157
+ this.room = r;
158
+ },
159
+ getHangUpStarted: () => this.hangUpStarted,
160
+ setHangUpStarted: (v) => {
161
+ this.hangUpStarted = v;
162
+ },
163
+ getEndCallSent: () => this.endCallSent,
164
+ setEndCallSent: (v) => {
165
+ this.endCallSent = v;
166
+ },
167
+ getEndToken: () => this.endToken,
168
+ clearEndToken: () => {
169
+ this.endToken = null;
170
+ },
171
+ getPageHideHandler: () => this.pageHideHandler,
172
+ setPageHideHandler: (h) => {
173
+ this.pageHideHandler = h;
174
+ },
175
+ };
176
+ }
177
+ setStatus(next) {
178
+ const previous = this.status;
179
+ this.status = transition(previous, next);
180
+ if (previous !== this.status) {
181
+ this.emitter.emit("statusChange", {
182
+ status: this.status,
183
+ previousStatus: previous,
184
+ });
185
+ }
186
+ }
187
+ fail(err) {
188
+ this.ringWatch.stop();
189
+ if (!isTerminal(this.status)) {
190
+ try {
191
+ this.setStatus("error");
192
+ }
193
+ catch {
194
+ this.status = "error";
195
+ }
196
+ }
197
+ this.emitter.emit("error", {
198
+ callId: this.callId,
199
+ code: err.code,
200
+ message: err.message,
201
+ });
202
+ this.ui.onStatus?.("error", err.message);
203
+ this.ui.onTerminal?.();
204
+ cleanupSession(this.asTeardownHost());
205
+ }
206
+ async onUnexpectedDisconnect() {
207
+ if (await this.ringWatch.checkAfterDisconnect())
208
+ return;
209
+ await this.hangUp();
210
+ }
211
+ async failNoAnswer() {
212
+ if (this.hangUpStarted || isTerminal(this.status))
213
+ return;
214
+ this.hangUpStarted = true;
215
+ this.ringWatch.stop();
216
+ await teardownMedia(this.asTeardownHost());
217
+ this.endCallSent = true;
218
+ this.fail(noAnswerError());
219
+ }
220
+ observeParticipant(participant) {
221
+ if (!isAgentParticipant(participant))
222
+ return;
223
+ participant.trackPublications.forEach((pub) => {
224
+ if (pub.track && pub.kind === Track.Kind.Audio) {
225
+ this.audio.attach(pub.track, participant.identity);
226
+ }
227
+ });
228
+ this.maybeAnswer(participant);
229
+ }
230
+ maybeAnswer(participant) {
231
+ if (this.status !== "ringing" || this.hangUpStarted)
232
+ return;
233
+ if (!isAgentParticipant(participant))
234
+ return;
235
+ if (!agentHasAudio(participant) && !this.hasAttachedAgentAudio(participant.identity)) {
236
+ return;
237
+ }
238
+ this.ringWatch.stop();
239
+ this.setStatus("answered");
240
+ this.emitter.emit("answered", {
241
+ callId: this.callId,
242
+ agentIdentity: participant.identity,
243
+ });
244
+ this.ui.onStatus?.("answered");
245
+ }
246
+ hasAttachedAgentAudio(identity) {
247
+ if (typeof document === "undefined")
248
+ return false;
249
+ const safe = identity.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
250
+ return Boolean(document.querySelector(`audio[data-identity="${safe}"]`));
251
+ }
252
+ }
@@ -0,0 +1,53 @@
1
+ import { CallSessionController, type CallUiHooks } from "./call.js";
2
+ import type { ActiveCall, InitOptions, Locale, RuntimeConfig, StartCallOptions } from "./types.js";
3
+ /**
4
+ * Default locale when `options.locale` is omitted (X10).
5
+ * `navigator.language` starting with `zh` → `zh-TW`; otherwise `en`.
6
+ */
7
+ export declare function defaultLocaleFromNavigator(language?: string | undefined): Locale;
8
+ /**
9
+ * @internal Allow the UI package to attach panel hooks without core importing UI.
10
+ */
11
+ export declare function registerUiHooksFactory(factory: ((call: CallSessionController, opts: StartCallOptions) => CallUiHooks) | null): void;
12
+ /**
13
+ * Headless CallEmbed client.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * CallEmbed.init({ publishableKey: "pk_…", apiBaseUrl: "" });
18
+ * const call = await CallEmbed.startCall({
19
+ * queue: "onboarding",
20
+ * hidePanel: true,
21
+ * on: {
22
+ * statusChange: ({ status }) => { … },
23
+ * ringing: () => { … },
24
+ * error: ({ code }) => { … },
25
+ * },
26
+ * });
27
+ * ```
28
+ */
29
+ export declare const CallEmbed: {
30
+ /**
31
+ * Configure the SDK for this page.
32
+ * @throws {@link CallEmbedError} `invalid_config` when keys/URL are missing or unsafe.
33
+ */
34
+ init(options: InitOptions): void;
35
+ /**
36
+ * Start a call and resolve once the room is connected (`ringing`), or reject
37
+ * on startup failure. Register `options.on` to observe `connecting` / `ringing`
38
+ * / startup `error` during the await.
39
+ *
40
+ * Panel UI appears only when `enableDefaultUi()` or `mountButton()` was used
41
+ * and `hidePanel` is not true.
42
+ *
43
+ * @returns The active call (status is typically `ringing` on success).
44
+ */
45
+ startCall(options: StartCallOptions): Promise<ActiveCall>;
46
+ /** Currently active call, or `null`. */
47
+ getActiveCall(): ActiveCall | null;
48
+ /** @internal Test helper — reset singleton state. */
49
+ _resetForTests(): void;
50
+ /** @internal Expose runtime for the UI package (locale / copy / debug). */
51
+ _getRuntime(): RuntimeConfig | null;
52
+ };
53
+ export type { ActiveCall, InitOptions, StartCallOptions };