@babelforce/babelconnect-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/CHANGELOG.md +18 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +110 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +34 -0
- package/dist/client.d.ts +114 -0
- package/dist/client.js +322 -0
- package/dist/embed/index.d.ts +79 -0
- package/dist/embed/index.js +115 -0
- package/dist/gen/babelconnect/v1/babelconnect_connect.d.ts +132 -0
- package/dist/gen/babelconnect/v1/babelconnect_connect.js +161 -0
- package/dist/gen/babelconnect/v1/babelconnect_pb.d.ts +2362 -0
- package/dist/gen/babelconnect/v1/babelconnect_pb.js +3086 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +19 -0
- package/dist/media.d.ts +52 -0
- package/dist/media.js +97 -0
- package/dist/state-cache.d.ts +30 -0
- package/dist/state-cache.js +106 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@babelforce/babelconnect-sdk` — the TypeScript SDK for babelconnect.
|
|
3
|
+
*
|
|
4
|
+
* - {@link BabelconnectClient}: a server-authoritative "dumb renderer" client over
|
|
5
|
+
* gRPC-web (state mirror + typed intents) with a pluggable WebRTC media leg.
|
|
6
|
+
* - {@link passwordGrant}: OAuth helper (via the server's `/oauth/token` endpoint).
|
|
7
|
+
* - {@link BrowserWebrtcMedia}: the default browser audio backend.
|
|
8
|
+
*
|
|
9
|
+
* For embedding the prebuilt agent app into a host page, import the `/embed` entry
|
|
10
|
+
* point: `@babelforce/babelconnect-sdk/embed`.
|
|
11
|
+
*/
|
|
12
|
+
export { BabelconnectClient, type ConnectOptions } from "./client.js";
|
|
13
|
+
export { StateCache } from "./state-cache.js";
|
|
14
|
+
export { passwordGrant } from "./auth.js";
|
|
15
|
+
export { BrowserWebrtcMedia, browserMediaFactory, type Media, type MediaFactory } from "./media.js";
|
|
16
|
+
export * from "./gen/babelconnect/v1/babelconnect_pb.js";
|
|
17
|
+
export { Agent } from "./gen/babelconnect/v1/babelconnect_connect.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@babelforce/babelconnect-sdk` — the TypeScript SDK for babelconnect.
|
|
3
|
+
*
|
|
4
|
+
* - {@link BabelconnectClient}: a server-authoritative "dumb renderer" client over
|
|
5
|
+
* gRPC-web (state mirror + typed intents) with a pluggable WebRTC media leg.
|
|
6
|
+
* - {@link passwordGrant}: OAuth helper (via the server's `/oauth/token` endpoint).
|
|
7
|
+
* - {@link BrowserWebrtcMedia}: the default browser audio backend.
|
|
8
|
+
*
|
|
9
|
+
* For embedding the prebuilt agent app into a host page, import the `/embed` entry
|
|
10
|
+
* point: `@babelforce/babelconnect-sdk/embed`.
|
|
11
|
+
*/
|
|
12
|
+
export { BabelconnectClient } from "./client.js";
|
|
13
|
+
export { StateCache } from "./state-cache.js";
|
|
14
|
+
export { passwordGrant } from "./auth.js";
|
|
15
|
+
export { BrowserWebrtcMedia, browserMediaFactory } from "./media.js";
|
|
16
|
+
// The generated babelconnect.v1 messages + enums (AgentView, CallState, Command, …)
|
|
17
|
+
// and the Agent service descriptor.
|
|
18
|
+
export * from "./gen/babelconnect/v1/babelconnect_pb.js";
|
|
19
|
+
export { Agent } from "./gen/babelconnect/v1/babelconnect_connect.js";
|
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pluggable media seam: one WebRTC leg per call. The SDK drives control over
|
|
3
|
+
* gRPC-web and hands the server's SDP offer to a `Media` to answer (audio flows
|
|
4
|
+
* between the browser and babelconnect-server). Mute/hold are server-side intents
|
|
5
|
+
* (`mute`/`hold`), so they are not part of this seam.
|
|
6
|
+
*/
|
|
7
|
+
export interface Media {
|
|
8
|
+
/**
|
|
9
|
+
* Consume the server's SDP offer and return the client's SDP answer, starting
|
|
10
|
+
* audio. Called once per call.
|
|
11
|
+
*
|
|
12
|
+
* @param iceServers STUN/TURN servers the server advertised on the call
|
|
13
|
+
* (`CallState.ice_servers`) — for off-host NAT traversal; empty in the
|
|
14
|
+
* in-cluster/LAN case.
|
|
15
|
+
*/
|
|
16
|
+
answer(offer: string, iceServers?: RTCIceServer[]): Promise<string>;
|
|
17
|
+
/** Tear down the peer (idempotent). */
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Builds the {@link Media} for a given call id. */
|
|
21
|
+
export type MediaFactory = (callId: string) => Media;
|
|
22
|
+
/**
|
|
23
|
+
* The default browser WebRTC backend: capture the mic, answer the server's offer
|
|
24
|
+
* (non-trickle ICE), and play the remote audio through a hidden `<audio>` element.
|
|
25
|
+
* A-law (PCMA) passthrough is negotiated by the server's offer.
|
|
26
|
+
*/
|
|
27
|
+
export declare class BrowserWebrtcMedia implements Media {
|
|
28
|
+
private readonly iceServers;
|
|
29
|
+
private pc?;
|
|
30
|
+
private local?;
|
|
31
|
+
private audioEl?;
|
|
32
|
+
private closed;
|
|
33
|
+
/** @param iceServers fallback ICE servers; the per-call `answer(offer, iceServers)`
|
|
34
|
+
* argument (from `CallState.ice_servers`) takes precedence when provided. */
|
|
35
|
+
constructor(iceServers?: RTCIceServer[]);
|
|
36
|
+
answer(offer: string, iceServers?: RTCIceServer[]): Promise<string>;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/** The default factory: a {@link BrowserWebrtcMedia} per call. */
|
|
40
|
+
export declare const browserMediaFactory: MediaFactory;
|
|
41
|
+
/** A server-advertised ICE server (shape of `CallState.ice_servers[]`). */
|
|
42
|
+
export interface IceServerLike {
|
|
43
|
+
urls: string[];
|
|
44
|
+
username: string;
|
|
45
|
+
credential: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Map the server-advertised `CallState.ice_servers` to the browser's
|
|
49
|
+
* {@link RTCIceServer} shape for `answer(offer, iceServers)`. Returns `undefined`
|
|
50
|
+
* when none are advertised (in-cluster/LAN) so the Media's own fallback applies.
|
|
51
|
+
*/
|
|
52
|
+
export declare function toRTCIceServers(servers: readonly IceServerLike[]): RTCIceServer[] | undefined;
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default browser WebRTC backend: capture the mic, answer the server's offer
|
|
3
|
+
* (non-trickle ICE), and play the remote audio through a hidden `<audio>` element.
|
|
4
|
+
* A-law (PCMA) passthrough is negotiated by the server's offer.
|
|
5
|
+
*/
|
|
6
|
+
export class BrowserWebrtcMedia {
|
|
7
|
+
iceServers;
|
|
8
|
+
pc;
|
|
9
|
+
local;
|
|
10
|
+
audioEl;
|
|
11
|
+
closed = false;
|
|
12
|
+
/** @param iceServers fallback ICE servers; the per-call `answer(offer, iceServers)`
|
|
13
|
+
* argument (from `CallState.ice_servers`) takes precedence when provided. */
|
|
14
|
+
constructor(iceServers = []) {
|
|
15
|
+
this.iceServers = iceServers;
|
|
16
|
+
}
|
|
17
|
+
async answer(offer, iceServers) {
|
|
18
|
+
// Prefer the per-call ICE servers the server advertised; fall back to any set
|
|
19
|
+
// at construction (kept for backward compatibility).
|
|
20
|
+
const pc = new RTCPeerConnection({ iceServers: iceServers ?? this.iceServers });
|
|
21
|
+
this.pc = pc;
|
|
22
|
+
// Remote audio: attach the inbound stream to a hidden, autoplaying <audio>.
|
|
23
|
+
pc.ontrack = (ev) => {
|
|
24
|
+
const stream = ev.streams[0];
|
|
25
|
+
if (!stream)
|
|
26
|
+
return;
|
|
27
|
+
let el = this.audioEl;
|
|
28
|
+
if (!el) {
|
|
29
|
+
el = document.createElement("audio");
|
|
30
|
+
el.autoplay = true;
|
|
31
|
+
el.style.display = "none";
|
|
32
|
+
document.body.appendChild(el);
|
|
33
|
+
this.audioEl = el;
|
|
34
|
+
}
|
|
35
|
+
el.srcObject = stream;
|
|
36
|
+
void el.play().catch(() => { });
|
|
37
|
+
};
|
|
38
|
+
// Capture the microphone and add it to the peer.
|
|
39
|
+
const local = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
40
|
+
this.local = local;
|
|
41
|
+
for (const track of local.getAudioTracks())
|
|
42
|
+
pc.addTrack(track, local);
|
|
43
|
+
await pc.setRemoteDescription({ type: "offer", sdp: offer });
|
|
44
|
+
const ans = await pc.createAnswer();
|
|
45
|
+
await pc.setLocalDescription(ans);
|
|
46
|
+
await waitIceGatheringComplete(pc);
|
|
47
|
+
const sdp = pc.localDescription?.sdp;
|
|
48
|
+
if (!sdp)
|
|
49
|
+
throw new Error("webrtc: empty local SDP after answer");
|
|
50
|
+
return sdp;
|
|
51
|
+
}
|
|
52
|
+
async close() {
|
|
53
|
+
if (this.closed)
|
|
54
|
+
return;
|
|
55
|
+
this.closed = true;
|
|
56
|
+
for (const t of this.local?.getTracks() ?? [])
|
|
57
|
+
t.stop();
|
|
58
|
+
this.local = undefined;
|
|
59
|
+
this.pc?.close();
|
|
60
|
+
this.pc = undefined;
|
|
61
|
+
if (this.audioEl) {
|
|
62
|
+
this.audioEl.srcObject = null;
|
|
63
|
+
this.audioEl.remove();
|
|
64
|
+
this.audioEl = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The default factory: a {@link BrowserWebrtcMedia} per call. */
|
|
69
|
+
export const browserMediaFactory = () => new BrowserWebrtcMedia();
|
|
70
|
+
/**
|
|
71
|
+
* Map the server-advertised `CallState.ice_servers` to the browser's
|
|
72
|
+
* {@link RTCIceServer} shape for `answer(offer, iceServers)`. Returns `undefined`
|
|
73
|
+
* when none are advertised (in-cluster/LAN) so the Media's own fallback applies.
|
|
74
|
+
*/
|
|
75
|
+
export function toRTCIceServers(servers) {
|
|
76
|
+
if (servers.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
return servers.map((s) => ({ urls: s.urls, username: s.username, credential: s.credential }));
|
|
79
|
+
}
|
|
80
|
+
/** Resolve once ICE gathering completes (non-trickle), or after `timeoutMs`. */
|
|
81
|
+
function waitIceGatheringComplete(pc, timeoutMs = 5000) {
|
|
82
|
+
if (pc.iceGatheringState === "complete")
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const done = () => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
pc.removeEventListener("icegatheringstatechange", check);
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
const check = () => {
|
|
91
|
+
if (pc.iceGatheringState === "complete")
|
|
92
|
+
done();
|
|
93
|
+
};
|
|
94
|
+
const timer = setTimeout(done, timeoutMs);
|
|
95
|
+
pc.addEventListener("icegatheringstatechange", check);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AgentView, type StateUpdate } from "./gen/babelconnect/v1/babelconnect_pb.js";
|
|
2
|
+
/**
|
|
3
|
+
* The client-side mirror of the server-authoritative {@link AgentView}.
|
|
4
|
+
*
|
|
5
|
+
* It applies `StateUpdate` snapshots and entity-level patches **mechanically** —
|
|
6
|
+
* there is no domain logic here, by design: the server reduces, the client
|
|
7
|
+
* renders. This is the TypeScript twin of the Go (`bcclient.StateCache`) and Dart
|
|
8
|
+
* (`StateCache`) reducers and is meant to be identical (and trivial) across the
|
|
9
|
+
* SDKs so they cannot drift.
|
|
10
|
+
*/
|
|
11
|
+
export declare class StateCache {
|
|
12
|
+
private view;
|
|
13
|
+
private seq;
|
|
14
|
+
private seenSeq;
|
|
15
|
+
private readonly listeners;
|
|
16
|
+
/** The current view (a deep copy, safe to hold/read). */
|
|
17
|
+
get current(): AgentView;
|
|
18
|
+
/**
|
|
19
|
+
* Fold one snapshot or patch into the view and notify listeners. Returns `true`
|
|
20
|
+
* if a seq gap was detected (a patch whose seq is not exactly the previous
|
|
21
|
+
* seq + 1) — the caller should resubscribe for a fresh snapshot. Error updates
|
|
22
|
+
* are handled by the client, not here.
|
|
23
|
+
*/
|
|
24
|
+
apply(u: StateUpdate): boolean;
|
|
25
|
+
/** Register a render callback, invoked immediately and on every update. Returns an unsubscribe fn. */
|
|
26
|
+
subscribe(fn: (v: AgentView) => void): () => void;
|
|
27
|
+
private reduce;
|
|
28
|
+
private putById;
|
|
29
|
+
private putByPeer;
|
|
30
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AgentView } from "./gen/babelconnect/v1/babelconnect_pb.js";
|
|
2
|
+
/**
|
|
3
|
+
* The client-side mirror of the server-authoritative {@link AgentView}.
|
|
4
|
+
*
|
|
5
|
+
* It applies `StateUpdate` snapshots and entity-level patches **mechanically** —
|
|
6
|
+
* there is no domain logic here, by design: the server reduces, the client
|
|
7
|
+
* renders. This is the TypeScript twin of the Go (`bcclient.StateCache`) and Dart
|
|
8
|
+
* (`StateCache`) reducers and is meant to be identical (and trivial) across the
|
|
9
|
+
* SDKs so they cannot drift.
|
|
10
|
+
*/
|
|
11
|
+
export class StateCache {
|
|
12
|
+
view = new AgentView();
|
|
13
|
+
seq = 0n;
|
|
14
|
+
seenSeq = false;
|
|
15
|
+
listeners = new Set();
|
|
16
|
+
/** The current view (a deep copy, safe to hold/read). */
|
|
17
|
+
get current() {
|
|
18
|
+
return this.view.clone();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fold one snapshot or patch into the view and notify listeners. Returns `true`
|
|
22
|
+
* if a seq gap was detected (a patch whose seq is not exactly the previous
|
|
23
|
+
* seq + 1) — the caller should resubscribe for a fresh snapshot. Error updates
|
|
24
|
+
* are handled by the client, not here.
|
|
25
|
+
*/
|
|
26
|
+
apply(u) {
|
|
27
|
+
let gap = false;
|
|
28
|
+
switch (u.update.case) {
|
|
29
|
+
case "snapshot":
|
|
30
|
+
this.view = u.update.value;
|
|
31
|
+
this.seq = u.seq;
|
|
32
|
+
this.seenSeq = true;
|
|
33
|
+
break;
|
|
34
|
+
case "patch":
|
|
35
|
+
if (this.seenSeq && u.seq !== this.seq + 1n)
|
|
36
|
+
gap = true;
|
|
37
|
+
this.seq = u.seq;
|
|
38
|
+
this.seenSeq = true;
|
|
39
|
+
this.reduce(u.update.value);
|
|
40
|
+
break;
|
|
41
|
+
default:
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const snapshot = this.view.clone();
|
|
45
|
+
for (const fn of this.listeners)
|
|
46
|
+
fn(snapshot);
|
|
47
|
+
return gap;
|
|
48
|
+
}
|
|
49
|
+
/** Register a render callback, invoked immediately and on every update. Returns an unsubscribe fn. */
|
|
50
|
+
subscribe(fn) {
|
|
51
|
+
this.listeners.add(fn);
|
|
52
|
+
fn(this.view.clone());
|
|
53
|
+
return () => this.listeners.delete(fn);
|
|
54
|
+
}
|
|
55
|
+
reduce(p) {
|
|
56
|
+
switch (p.change.case) {
|
|
57
|
+
case "agent":
|
|
58
|
+
this.view.agent = p.change.value;
|
|
59
|
+
break;
|
|
60
|
+
case "callUpsert":
|
|
61
|
+
this.putById(this.view.activeCalls, p.change.value);
|
|
62
|
+
break;
|
|
63
|
+
case "callRemove":
|
|
64
|
+
this.view.activeCalls = this.view.activeCalls.filter((c) => c.id !== p.change.value);
|
|
65
|
+
break;
|
|
66
|
+
case "wrapUp":
|
|
67
|
+
this.view.wrapUp = p.change.value;
|
|
68
|
+
break;
|
|
69
|
+
case "smsUpsert":
|
|
70
|
+
// SMS conversations are keyed by PEER (the stable thread identity), not the
|
|
71
|
+
// backend conversation id — mirror the server reducer + sdk-go/sdk-dart caches.
|
|
72
|
+
this.putByPeer(this.view.sms, p.change.value);
|
|
73
|
+
break;
|
|
74
|
+
case "smsRemove":
|
|
75
|
+
// The sms_remove patch value is the peer.
|
|
76
|
+
this.view.sms = this.view.sms.filter((c) => c.peer !== p.change.value);
|
|
77
|
+
break;
|
|
78
|
+
case "conferenceUpsert":
|
|
79
|
+
this.putById(this.view.conferences, p.change.value);
|
|
80
|
+
break;
|
|
81
|
+
case "conferenceRemove":
|
|
82
|
+
this.view.conferences = this.view.conferences.filter((c) => c.id !== p.change.value);
|
|
83
|
+
break;
|
|
84
|
+
case "config":
|
|
85
|
+
this.view.config = p.change.value;
|
|
86
|
+
break;
|
|
87
|
+
// `notification` is transient (handled by the client as an event), not stored.
|
|
88
|
+
default:
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
putById(list, item) {
|
|
93
|
+
const i = list.findIndex((x) => x.id === item.id);
|
|
94
|
+
if (i >= 0)
|
|
95
|
+
list[i] = item;
|
|
96
|
+
else
|
|
97
|
+
list.push(item);
|
|
98
|
+
}
|
|
99
|
+
putByPeer(list, item) {
|
|
100
|
+
const i = list.findIndex((x) => x.peer === item.peer);
|
|
101
|
+
if (i >= 0)
|
|
102
|
+
list[i] = item;
|
|
103
|
+
else
|
|
104
|
+
list.push(item);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@babelforce/babelconnect-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript SDK for babelconnect — server-authoritative agent state over gRPC-web, native WebRTC audio, and an embeddable widget (iframe + postMessage) for the Flutter web app.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://babelforce.github.io/babelconnect-sdk-docs/",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"NOTICE"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./embed": {
|
|
21
|
+
"types": "./dist/embed/index.d.ts",
|
|
22
|
+
"import": "./dist/embed/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"generate": "echo 'run `task gen` at the repo root (buf generate emits src/gen)'",
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
29
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
30
|
+
"docs": "typedoc",
|
|
31
|
+
"docs:md": "typedoc --options typedoc.docusaurus.json",
|
|
32
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@bufbuild/protobuf": "^1.10.1",
|
|
36
|
+
"@connectrpc/connect": "^1.6.1",
|
|
37
|
+
"@connectrpc/connect-web": "^1.6.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@bufbuild/protoc-gen-es": "^1.10.1",
|
|
41
|
+
"@connectrpc/protoc-gen-connect-es": "^1.6.1",
|
|
42
|
+
"typedoc": "^0.27.0",
|
|
43
|
+
"typedoc-plugin-markdown": "^4.4.0",
|
|
44
|
+
"typescript": "^5.6.0"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"registry": "https://registry.npmjs.org/"
|
|
49
|
+
}
|
|
50
|
+
}
|