@signalhousellc/sdk 1.0.51 → 1.0.53

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.
@@ -0,0 +1,226 @@
1
+ import JsSIP from "jssip";
2
+ import { Call } from "./Call.js";
3
+
4
+ /**
5
+ * @typedef {Object} SipCredentials
6
+ * @property {string} username - SIP username (e.g. "wrtc_<hex>"); from /voice/tokens response.
7
+ * @property {string} password - Plaintext SIP password.
8
+ * @property {string} domain - SIP domain (e.g. "webrtc.signalhouse.io").
9
+ * @property {string} wss_url - WebSocket Secure URL to OpenSIPS.
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} VoiceTokenResponse
14
+ * @property {string} token - JWT (informational; SDK does not currently re-send it).
15
+ * @property {string} identity - The wrtc_<hex> SIP username.
16
+ * @property {string} expires_at - ISO timestamp.
17
+ * @property {SipCredentials} sip_credentials
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} ConnectParams
22
+ * @property {string} to - Destination — E.164 ("+15551234") or full SIP URI.
23
+ * @property {string} [from] - Caller ID to present (must be an account-owned DID).
24
+ * Sent as a P-Preferred-Identity header per RFC 3325; OpenSIPS validates
25
+ * ownership and uses it as the carrier-bound From. If omitted, OpenSIPS
26
+ * falls back to the account's default outbound DID.
27
+ * @property {MediaStream} [mediaStream] - Pre-acquired stream from getUserMedia.
28
+ * When absent the SDK calls getUserMedia({audio: true}) itself.
29
+ * @property {RTCConfiguration} [pcConfig] - WebRTC peer-connection config.
30
+ * @property {string[]} [extraHeaders] - Additional SIP headers to attach to
31
+ * the outbound INVITE (advanced).
32
+ */
33
+
34
+ /**
35
+ * Browser-side SignalHouse voice client. Wraps JsSIP behind a Twilio-shape
36
+ * Device + Call API.
37
+ *
38
+ * const device = new Device(tokenResponse);
39
+ * await device.register();
40
+ * const call = await device.connect({ to: "+15551234", from: "+15559999" });
41
+ * device.on("incoming", call => call.accept());
42
+ *
43
+ * Token shape mirrors what `POST /voice/tokens` returns from the SignalHouse
44
+ * voice-backend. You can also hand in the inner `sip_credentials` object
45
+ * directly if your app already unwrapped the response.
46
+ *
47
+ * Events: "registered", "unregistered", "registrationFailed", "incoming",
48
+ * "connected" (WSS established), "disconnected", "error".
49
+ */
50
+ export class Device {
51
+ /**
52
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
53
+ * @param {Object} [opts]
54
+ * @param {number} [opts.registerExpires=600] - REGISTER expires interval, in seconds.
55
+ * @param {string} [opts.userAgent="SignalHouse-Voice-SDK/0.1"]
56
+ * @param {boolean} [opts.autoRegister=false] - Call register() on construct.
57
+ */
58
+ constructor(tokenOrCreds, opts = {}) {
59
+ const creds = tokenOrCreds?.sip_credentials || tokenOrCreds;
60
+ if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
61
+ throw new Error("Device: token/credentials missing required fields (username, password, domain, wss_url)");
62
+ }
63
+
64
+ this.identity = tokenOrCreds?.identity || creds.username;
65
+ this.expiresAt = tokenOrCreds?.expires_at || null;
66
+
67
+ this._credentials = creds;
68
+ this._opts = {
69
+ registerExpires: opts.registerExpires ?? 600,
70
+ userAgent: opts.userAgent ?? "SignalHouse-Voice-SDK/0.1",
71
+ };
72
+
73
+ this._ua = null;
74
+ this._listeners = new Map();
75
+ this._registered = false;
76
+
77
+ if (opts.autoRegister) this.register().catch(() => { /* surfaced via "registrationFailed" event */ });
78
+ }
79
+
80
+ // ─── Public API ─────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Open the WSS connection and REGISTER. Resolves on first "registered"
84
+ * event, rejects on first "registrationFailed".
85
+ * @returns {Promise<void>}
86
+ */
87
+ register() {
88
+ if (this._registered) return Promise.resolve();
89
+ if (!this._ua) this._initUA();
90
+
91
+ return new Promise((resolve, reject) => {
92
+ const onOk = () => {
93
+ this._ua.removeListener("registered", onOk);
94
+ this._ua.removeListener("registrationFailed", onFail);
95
+ resolve();
96
+ };
97
+ const onFail = (e) => {
98
+ this._ua.removeListener("registered", onOk);
99
+ this._ua.removeListener("registrationFailed", onFail);
100
+ reject(new Error(`registrationFailed: ${e?.cause || "unknown"}`));
101
+ };
102
+ this._ua.on("registered", onOk);
103
+ this._ua.on("registrationFailed", onFail);
104
+ this._ua.start();
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Unregister and close the WSS connection. Idempotent.
110
+ * @returns {Promise<void>}
111
+ */
112
+ unregister() {
113
+ if (!this._ua) return Promise.resolve();
114
+ return new Promise((resolve) => {
115
+ const done = () => { try { this._ua.stop(); } catch {} resolve(); };
116
+ if (!this._registered) return done();
117
+ this._ua.once("unregistered", done);
118
+ try { this._ua.unregister(); }
119
+ catch { done(); }
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Place an outbound call. Returns a Call once the INVITE is sent.
125
+ * The returned Call fires "progress" → "accepted" → "confirmed" as
126
+ * the dialog establishes; listen for those before assuming audio is live.
127
+ *
128
+ * @param {ConnectParams} params
129
+ * @returns {Promise<Call>}
130
+ */
131
+ async connect({ to, from, mediaStream, pcConfig, extraHeaders = [] } = {}) {
132
+ if (!this._ua || !this._registered) {
133
+ throw new Error("Device.connect: not registered yet — call register() first");
134
+ }
135
+ if (!to) throw new Error("Device.connect: 'to' is required");
136
+
137
+ const target = /^sip:/i.test(to)
138
+ ? to
139
+ : `sip:${String(to).replace(/^\+?/, "")}@${this._credentials.domain}`;
140
+
141
+ // P-Preferred-Identity (RFC 3325) — OpenSIPS validates this is an
142
+ // account-owned DID, then uses it as the carrier From header. If
143
+ // not supplied, OpenSIPS falls back to the account's default DID.
144
+ const headers = [...extraHeaders];
145
+ if (from) {
146
+ headers.push(`P-Preferred-Identity: <sip:${from}@${this._credentials.domain}>`);
147
+ }
148
+
149
+ const stream = mediaStream || (await navigator.mediaDevices.getUserMedia({ audio: true, video: false }));
150
+
151
+ // Default to no STUN servers — SignalHouse's rtpengine is configured
152
+ // with `trust-address` (uses the actual source IP from incoming RTP
153
+ // packets rather than the SDP), so host candidates are enough and
154
+ // ICE gathering completes in milliseconds instead of waiting on
155
+ // public STUN. Customers behind restrictive NATs can override via
156
+ // `pcConfig: { iceServers: [{urls: "stun:..."}] }`.
157
+ const session = this._ua.call(target, {
158
+ mediaConstraints: { audio: true, video: false },
159
+ mediaStream: stream,
160
+ pcConfig: pcConfig || { iceServers: [] },
161
+ rtcOfferConstraints: { offerToReceiveAudio: 1, offerToReceiveVideo: 0 },
162
+ extraHeaders: headers,
163
+ });
164
+
165
+ return new Call({ session, direction: "outbound" });
166
+ }
167
+
168
+ /**
169
+ * Attach event listener. See class JSDoc for event list.
170
+ */
171
+ on(event, handler) {
172
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
173
+ this._listeners.get(event).add(handler);
174
+ }
175
+
176
+ /**
177
+ * Remove a previously-registered listener.
178
+ */
179
+ off(event, handler) {
180
+ this._listeners.get(event)?.delete(handler);
181
+ }
182
+
183
+ /** True iff currently REGISTERED. */
184
+ get isRegistered() { return this._registered; }
185
+
186
+ // ─── Internal ───────────────────────────────────────────────────────────
187
+
188
+ _emit(event, payload) {
189
+ for (const fn of this._listeners.get(event) || []) {
190
+ try { fn(payload); }
191
+ catch (err) { console.error(`[Device.${event} handler]`, err); }
192
+ }
193
+ }
194
+
195
+ _initUA() {
196
+ const socket = new JsSIP.WebSocketInterface(this._credentials.wss_url);
197
+ this._ua = new JsSIP.UA({
198
+ sockets: [socket],
199
+ uri: `sip:${this._credentials.username}@${this._credentials.domain}`,
200
+ password: this._credentials.password,
201
+ register: true,
202
+ register_expires: this._opts.registerExpires,
203
+ user_agent: this._opts.userAgent,
204
+ });
205
+
206
+ this._ua.on("connecting", () => this._emit("connecting"));
207
+ this._ua.on("connected", () => this._emit("connected"));
208
+ this._ua.on("disconnected", (e) => this._emit("disconnected", { reason: e?.reason }));
209
+ this._ua.on("registered", () => { this._registered = true; this._emit("registered"); });
210
+ this._ua.on("unregistered", () => { this._registered = false; this._emit("unregistered"); });
211
+ this._ua.on("registrationFailed", (e) => {
212
+ this._registered = false;
213
+ this._emit("registrationFailed", {
214
+ cause: e?.cause,
215
+ statusCode: e?.response?.status_code,
216
+ reason: e?.response?.reason_phrase,
217
+ });
218
+ });
219
+
220
+ this._ua.on("newRTCSession", (e) => {
221
+ if (e.originator !== "remote") return; // outbound: we already wrap in connect()
222
+ const call = new Call({ session: e.session, direction: "inbound", request: e.request });
223
+ this._emit("incoming", call);
224
+ });
225
+ }
226
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Browser-only entry point for the SignalHouse voice SDK.
3
+ *
4
+ * Imported as a subpath:
5
+ *
6
+ * import { Device } from "@signalhousellc/sdk/voice-browser";
7
+ *
8
+ * Loads JsSIP transitively. Node-only consumers should NOT import this
9
+ * subpath; the REST surface (`sdk.voice.calls`, `sdk.voice.tokens`, etc.)
10
+ * is exported from the package root and is JsSIP-free.
11
+ *
12
+ * Pairs with `sdk.voice.tokens.create()` from the REST surface — call that
13
+ * server-side to mint a token, hand the response to `new Device(token)`
14
+ * in the browser.
15
+ */
16
+ export { Device } from "./Device.js";
17
+ export { Call } from "./Call.js";