assemblyai 4.34.6 → 4.35.3

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.
@@ -531,6 +531,24 @@ function toInt16View(audio) {
531
531
  }
532
532
  const defaultStreamingUrl = "wss://streaming.assemblyai.com/v3/ws";
533
533
  const terminateSessionMessage = `{"type":"Terminate"}`;
534
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1000;
535
+ const DEFAULT_MAX_CONNECTION_RETRIES = 2;
536
+ const DEFAULT_CONNECTION_RETRY_DELAY_MS = 500;
537
+ /**
538
+ * Close/error codes that signal a permanent client-side problem (auth,
539
+ * billing, malformed config). A retry would hit the same failure, so the
540
+ * connection is never retried on these.
541
+ */
542
+ const NON_RETRYABLE_CLOSE_CODES = new Set([
543
+ StreamingErrorType.BadSampleRate,
544
+ StreamingErrorType.AuthFailed,
545
+ StreamingErrorType.InsufficientFunds,
546
+ StreamingErrorType.FreeTierUser,
547
+ StreamingErrorType.BadSchema,
548
+ ]);
549
+ function isRetryableCloseCode(code) {
550
+ return code !== 1000 && !NON_RETRYABLE_CLOSE_CODES.has(code);
551
+ }
534
552
  /**
535
553
  * Per-send chunk cap in milliseconds for the dual-channel mixer. The streaming
536
554
  * server rejects audio messages longer than 1000 ms (`Input Duration Error`).
@@ -664,8 +682,12 @@ class StreamingTranscriber {
664
682
  searchParams.set("speech_model", this.params.speechModel.toString());
665
683
  }
666
684
  if (this.params.languageCode !== undefined) {
685
+ console.warn("[Deprecation Warning] `languageCode` is deprecated and will be removed in a future release. Please use `languageCodes` instead.");
667
686
  searchParams.set("language_code", this.params.languageCode);
668
687
  }
688
+ if (this.params.languageCodes !== undefined) {
689
+ searchParams.set("language_codes", JSON.stringify(this.params.languageCodes));
690
+ }
669
691
  if (this.params.languageDetection !== undefined) {
670
692
  searchParams.set("language_detection", this.params.languageDetection.toString());
671
693
  }
@@ -739,12 +761,81 @@ class StreamingTranscriber {
739
761
  on(event, listener) {
740
762
  this.listeners[event] = listener;
741
763
  }
742
- connect() {
743
- return new Promise((resolve) => {
744
- if (this.socket) {
745
- throw new Error("Already connected");
764
+ /**
765
+ * Open the streaming session.
766
+ *
767
+ * Resolves with the server's `Begin` event once the handshake completes. A
768
+ * single attempt is bounded by `connectTimeout` (default 1000ms); transient
769
+ * failures (timeout, network drop, unexpected close) are retried up to
770
+ * `maxConnectionRetries` times (default 2), waiting `connectionRetryDelay`
771
+ * (default 500ms) between attempts. Permanent failures (auth, insufficient
772
+ * funds, malformed config) are not retried.
773
+ *
774
+ * Unlike previously, a failed connection now rejects this promise rather
775
+ * than only invoking the `error` listener — necessary for the caller (and
776
+ * the retry loop) to observe the failure.
777
+ */
778
+ async connect() {
779
+ if (this.socket) {
780
+ throw new Error("Already connected");
781
+ }
782
+ const maxRetries = this.params.maxConnectionRetries ?? DEFAULT_MAX_CONNECTION_RETRIES;
783
+ const retryDelay = this.params.connectionRetryDelay ?? DEFAULT_CONNECTION_RETRY_DELAY_MS;
784
+ let lastError;
785
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
786
+ try {
787
+ return await this.connectOnce();
788
+ }
789
+ catch (err) {
790
+ lastError = err;
791
+ const retryable = err.retryable === true;
792
+ if (!retryable || attempt === maxRetries) {
793
+ throw err;
794
+ }
795
+ console.warn(`Streaming connect attempt ${attempt + 1}/${maxRetries + 1} failed (${err.message}); retrying`);
796
+ if (retryDelay > 0) {
797
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
798
+ }
746
799
  }
800
+ }
801
+ // The loop above always returns or throws; this only satisfies the type
802
+ // checker that a value is produced on every path.
803
+ throw lastError ?? new Error("Failed to connect to streaming server");
804
+ }
805
+ connectOnce() {
806
+ return new Promise((resolve, reject) => {
747
807
  const url = this.connectionUrl();
808
+ const timeoutMs = this.params.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT_MS;
809
+ // `settled` flips once this attempt has resolved (`Begin`) or rejected
810
+ // (timeout / pre-`Begin` close / error). Before it flips the socket
811
+ // handlers drive this promise; after it flips they revert to normal
812
+ // runtime dispatch (close / error / message listeners).
813
+ let settled = false;
814
+ let timer;
815
+ const failAttempt = (error) => {
816
+ if (settled)
817
+ return;
818
+ settled = true;
819
+ if (timer)
820
+ clearTimeout(timer);
821
+ this.discardPendingSocket();
822
+ reject(error);
823
+ };
824
+ const succeed = (begin) => {
825
+ if (settled)
826
+ return;
827
+ settled = true;
828
+ if (timer)
829
+ clearTimeout(timer);
830
+ resolve(begin);
831
+ };
832
+ if (timeoutMs > 0) {
833
+ timer = setTimeout(() => {
834
+ const err = new StreamingError(`Streaming connection timed out after ${timeoutMs}ms`);
835
+ err.retryable = true;
836
+ failAttempt(err);
837
+ }, timeoutMs);
838
+ }
748
839
  if (this.token) {
749
840
  this.socket = factory(url.toString());
750
841
  }
@@ -765,6 +856,15 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
765
856
  reason = StreamingErrorMessages[code];
766
857
  }
767
858
  }
859
+ // A close before `Begin` is a failed connection attempt — reject so
860
+ // connect() can retry (or surface a permanent failure).
861
+ if (!settled) {
862
+ const err = new StreamingError(reason || `Streaming connection closed (code=${code})`);
863
+ err.code = code;
864
+ err.retryable = isRetryableCloseCode(code);
865
+ failAttempt(err);
866
+ return;
867
+ }
768
868
  // Stop the flush timer when the socket is gone (server-initiated close,
769
869
  // network drop, etc.) — otherwise subsequent ticks call send() on a
770
870
  // closed socket and spam the error listener.
@@ -775,25 +875,37 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
775
875
  this.listeners.close?.(code, reason);
776
876
  };
777
877
  this.socket.onerror = (event) => {
778
- if (event.error)
779
- this.listeners.error?.(event.error);
780
- else
781
- this.listeners.error?.(new Error(event.message));
878
+ const error = event.error ?? new Error(event.message);
879
+ // A socket error before `Begin` is a failed attempt → reject/retry.
880
+ if (!settled) {
881
+ error.retryable = true;
882
+ failAttempt(error);
883
+ return;
884
+ }
885
+ this.listeners.error?.(error);
782
886
  };
783
887
  this.socket.onmessage = ({ data }) => {
784
888
  const message = JSON.parse(data.toString());
785
889
  if ("error" in message) {
786
890
  const err = new StreamingError(message.error);
787
891
  if ("error_code" in message) {
788
- err.code =
789
- message.error_code;
892
+ err.code = message.error_code;
893
+ }
894
+ // A server error frame before `Begin` fails the attempt; the code
895
+ // decides whether a retry is worthwhile.
896
+ if (!settled) {
897
+ const attemptErr = err;
898
+ attemptErr.retryable =
899
+ err.code === undefined ? true : isRetryableCloseCode(err.code);
900
+ failAttempt(attemptErr);
901
+ return;
790
902
  }
791
903
  this.listeners.error?.(err);
792
904
  return;
793
905
  }
794
906
  switch (message.type) {
795
907
  case "Begin": {
796
- resolve(message);
908
+ succeed(message);
797
909
  this.listeners.open?.(message);
798
910
  break;
799
911
  }
@@ -840,6 +952,20 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
840
952
  };
841
953
  });
842
954
  }
955
+ /** Tear down a half-open socket from a failed connection attempt. */
956
+ discardPendingSocket() {
957
+ if (!this.socket)
958
+ return;
959
+ try {
960
+ if (this.socket.removeAllListeners)
961
+ this.socket.removeAllListeners();
962
+ this.socket.close();
963
+ }
964
+ catch {
965
+ // Best-effort cleanup; a half-open socket may throw on close.
966
+ }
967
+ this.socket = undefined;
968
+ }
843
969
  /**
844
970
  * Returns a WritableStream that pumps PCM chunks into `sendAudio`. Single-channel
845
971
  * only — in dual-channel mode use `sendAudio(pcm, { channel })` directly, since
@@ -1113,6 +1239,16 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1113
1239
  };
1114
1240
  this.send(JSON.stringify(message));
1115
1241
  }
1242
+ /**
1243
+ * Reset the server's inactivity timer. Only needed when the session was
1244
+ * created with `inactivityTimeout` and no audio is being sent.
1245
+ */
1246
+ keepAlive() {
1247
+ const message = {
1248
+ type: "KeepAlive",
1249
+ };
1250
+ this.send(JSON.stringify(message));
1251
+ }
1116
1252
  send(data) {
1117
1253
  if (!this.socket || this.socket.readyState !== this.socket.OPEN) {
1118
1254
  throw new Error("Socket is not open for communication");
@@ -1168,7 +1304,7 @@ if (typeof navigator !== "undefined" && navigator.userAgent) {
1168
1304
  defaultUserAgentString += navigator.userAgent;
1169
1305
  }
1170
1306
  const defaultUserAgent = {
1171
- sdk: { name: "JavaScript", version: "4.34.6" },
1307
+ sdk: { name: "JavaScript", version: "4.35.3" },
1172
1308
  };
1173
1309
  if (typeof process !== "undefined") {
1174
1310
  if (process.versions.node && defaultUserAgentString.indexOf("Node") === -1) {
@@ -579,6 +579,24 @@ function toInt16View(audio) {
579
579
  }
580
580
  const defaultStreamingUrl = "wss://streaming.assemblyai.com/v3/ws";
581
581
  const terminateSessionMessage = `{"type":"Terminate"}`;
582
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1000;
583
+ const DEFAULT_MAX_CONNECTION_RETRIES = 2;
584
+ const DEFAULT_CONNECTION_RETRY_DELAY_MS = 500;
585
+ /**
586
+ * Close/error codes that signal a permanent client-side problem (auth,
587
+ * billing, malformed config). A retry would hit the same failure, so the
588
+ * connection is never retried on these.
589
+ */
590
+ const NON_RETRYABLE_CLOSE_CODES = new Set([
591
+ StreamingErrorType.BadSampleRate,
592
+ StreamingErrorType.AuthFailed,
593
+ StreamingErrorType.InsufficientFunds,
594
+ StreamingErrorType.FreeTierUser,
595
+ StreamingErrorType.BadSchema,
596
+ ]);
597
+ function isRetryableCloseCode(code) {
598
+ return code !== 1000 && !NON_RETRYABLE_CLOSE_CODES.has(code);
599
+ }
582
600
  /**
583
601
  * Per-send chunk cap in milliseconds for the dual-channel mixer. The streaming
584
602
  * server rejects audio messages longer than 1000 ms (`Input Duration Error`).
@@ -710,8 +728,12 @@ class StreamingTranscriber {
710
728
  searchParams.set("speech_model", this.params.speechModel.toString());
711
729
  }
712
730
  if (this.params.languageCode !== undefined) {
731
+ console.warn("[Deprecation Warning] `languageCode` is deprecated and will be removed in a future release. Please use `languageCodes` instead.");
713
732
  searchParams.set("language_code", this.params.languageCode);
714
733
  }
734
+ if (this.params.languageCodes !== undefined) {
735
+ searchParams.set("language_codes", JSON.stringify(this.params.languageCodes));
736
+ }
715
737
  if (this.params.languageDetection !== undefined) {
716
738
  searchParams.set("language_detection", this.params.languageDetection.toString());
717
739
  }
@@ -785,12 +807,85 @@ class StreamingTranscriber {
785
807
  on(event, listener) {
786
808
  this.listeners[event] = listener;
787
809
  }
810
+ /**
811
+ * Open the streaming session.
812
+ *
813
+ * Resolves with the server's `Begin` event once the handshake completes. A
814
+ * single attempt is bounded by `connectTimeout` (default 1000ms); transient
815
+ * failures (timeout, network drop, unexpected close) are retried up to
816
+ * `maxConnectionRetries` times (default 2), waiting `connectionRetryDelay`
817
+ * (default 500ms) between attempts. Permanent failures (auth, insufficient
818
+ * funds, malformed config) are not retried.
819
+ *
820
+ * Unlike previously, a failed connection now rejects this promise rather
821
+ * than only invoking the `error` listener — necessary for the caller (and
822
+ * the retry loop) to observe the failure.
823
+ */
788
824
  connect() {
789
- return new Promise((resolve) => {
825
+ return __awaiter(this, void 0, void 0, function* () {
826
+ var _a, _b;
790
827
  if (this.socket) {
791
828
  throw new Error("Already connected");
792
829
  }
830
+ const maxRetries = (_a = this.params.maxConnectionRetries) !== null && _a !== void 0 ? _a : DEFAULT_MAX_CONNECTION_RETRIES;
831
+ const retryDelay = (_b = this.params.connectionRetryDelay) !== null && _b !== void 0 ? _b : DEFAULT_CONNECTION_RETRY_DELAY_MS;
832
+ let lastError;
833
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
834
+ try {
835
+ return yield this.connectOnce();
836
+ }
837
+ catch (err) {
838
+ lastError = err;
839
+ const retryable = err.retryable === true;
840
+ if (!retryable || attempt === maxRetries) {
841
+ throw err;
842
+ }
843
+ console.warn(`Streaming connect attempt ${attempt + 1}/${maxRetries + 1} failed (${err.message}); retrying`);
844
+ if (retryDelay > 0) {
845
+ yield new Promise((resolve) => setTimeout(resolve, retryDelay));
846
+ }
847
+ }
848
+ }
849
+ // The loop above always returns or throws; this only satisfies the type
850
+ // checker that a value is produced on every path.
851
+ throw lastError !== null && lastError !== void 0 ? lastError : new Error("Failed to connect to streaming server");
852
+ });
853
+ }
854
+ connectOnce() {
855
+ return new Promise((resolve, reject) => {
856
+ var _a;
793
857
  const url = this.connectionUrl();
858
+ const timeoutMs = (_a = this.params.connectTimeout) !== null && _a !== void 0 ? _a : DEFAULT_CONNECT_TIMEOUT_MS;
859
+ // `settled` flips once this attempt has resolved (`Begin`) or rejected
860
+ // (timeout / pre-`Begin` close / error). Before it flips the socket
861
+ // handlers drive this promise; after it flips they revert to normal
862
+ // runtime dispatch (close / error / message listeners).
863
+ let settled = false;
864
+ let timer;
865
+ const failAttempt = (error) => {
866
+ if (settled)
867
+ return;
868
+ settled = true;
869
+ if (timer)
870
+ clearTimeout(timer);
871
+ this.discardPendingSocket();
872
+ reject(error);
873
+ };
874
+ const succeed = (begin) => {
875
+ if (settled)
876
+ return;
877
+ settled = true;
878
+ if (timer)
879
+ clearTimeout(timer);
880
+ resolve(begin);
881
+ };
882
+ if (timeoutMs > 0) {
883
+ timer = setTimeout(() => {
884
+ const err = new StreamingError(`Streaming connection timed out after ${timeoutMs}ms`);
885
+ err.retryable = true;
886
+ failAttempt(err);
887
+ }, timeoutMs);
888
+ }
794
889
  if (this.token) {
795
890
  this.socket = factory(url.toString());
796
891
  }
@@ -808,6 +903,15 @@ class StreamingTranscriber {
808
903
  reason = StreamingErrorMessages[code];
809
904
  }
810
905
  }
906
+ // A close before `Begin` is a failed connection attempt — reject so
907
+ // connect() can retry (or surface a permanent failure).
908
+ if (!settled) {
909
+ const err = new StreamingError(reason || `Streaming connection closed (code=${code})`);
910
+ err.code = code;
911
+ err.retryable = isRetryableCloseCode(code);
912
+ failAttempt(err);
913
+ return;
914
+ }
811
915
  // Stop the flush timer when the socket is gone (server-initiated close,
812
916
  // network drop, etc.) — otherwise subsequent ticks call send() on a
813
917
  // closed socket and spam the error listener.
@@ -818,11 +922,15 @@ class StreamingTranscriber {
818
922
  (_b = (_a = this.listeners).close) === null || _b === void 0 ? void 0 : _b.call(_a, code, reason);
819
923
  };
820
924
  this.socket.onerror = (event) => {
821
- var _a, _b, _c, _d;
822
- if (event.error)
823
- (_b = (_a = this.listeners).error) === null || _b === void 0 ? void 0 : _b.call(_a, event.error);
824
- else
825
- (_d = (_c = this.listeners).error) === null || _d === void 0 ? void 0 : _d.call(_c, new Error(event.message));
925
+ var _a, _b, _c;
926
+ const error = (_a = event.error) !== null && _a !== void 0 ? _a : new Error(event.message);
927
+ // A socket error before `Begin` is a failed attempt reject/retry.
928
+ if (!settled) {
929
+ error.retryable = true;
930
+ failAttempt(error);
931
+ return;
932
+ }
933
+ (_c = (_b = this.listeners).error) === null || _c === void 0 ? void 0 : _c.call(_b, error);
826
934
  };
827
935
  this.socket.onmessage = ({ data }) => {
828
936
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
@@ -830,15 +938,23 @@ class StreamingTranscriber {
830
938
  if ("error" in message) {
831
939
  const err = new StreamingError(message.error);
832
940
  if ("error_code" in message) {
833
- err.code =
834
- message.error_code;
941
+ err.code = message.error_code;
942
+ }
943
+ // A server error frame before `Begin` fails the attempt; the code
944
+ // decides whether a retry is worthwhile.
945
+ if (!settled) {
946
+ const attemptErr = err;
947
+ attemptErr.retryable =
948
+ err.code === undefined ? true : isRetryableCloseCode(err.code);
949
+ failAttempt(attemptErr);
950
+ return;
835
951
  }
836
952
  (_b = (_a = this.listeners).error) === null || _b === void 0 ? void 0 : _b.call(_a, err);
837
953
  return;
838
954
  }
839
955
  switch (message.type) {
840
956
  case "Begin": {
841
- resolve(message);
957
+ succeed(message);
842
958
  (_d = (_c = this.listeners).open) === null || _d === void 0 ? void 0 : _d.call(_c, message);
843
959
  break;
844
960
  }
@@ -885,6 +1001,20 @@ class StreamingTranscriber {
885
1001
  };
886
1002
  });
887
1003
  }
1004
+ /** Tear down a half-open socket from a failed connection attempt. */
1005
+ discardPendingSocket() {
1006
+ if (!this.socket)
1007
+ return;
1008
+ try {
1009
+ if (this.socket.removeAllListeners)
1010
+ this.socket.removeAllListeners();
1011
+ this.socket.close();
1012
+ }
1013
+ catch (_a) {
1014
+ // Best-effort cleanup; a half-open socket may throw on close.
1015
+ }
1016
+ this.socket = undefined;
1017
+ }
888
1018
  /**
889
1019
  * Returns a WritableStream that pumps PCM chunks into `sendAudio`. Single-channel
890
1020
  * only — in dual-channel mode use `sendAudio(pcm, { channel })` directly, since
@@ -1158,6 +1288,16 @@ class StreamingTranscriber {
1158
1288
  };
1159
1289
  this.send(JSON.stringify(message));
1160
1290
  }
1291
+ /**
1292
+ * Reset the server's inactivity timer. Only needed when the session was
1293
+ * created with `inactivityTimeout` and no audio is being sent.
1294
+ */
1295
+ keepAlive() {
1296
+ const message = {
1297
+ type: "KeepAlive",
1298
+ };
1299
+ this.send(JSON.stringify(message));
1300
+ }
1161
1301
  send(data) {
1162
1302
  if (!this.socket || this.socket.readyState !== this.socket.OPEN) {
1163
1303
  throw new Error("Socket is not open for communication");
@@ -577,6 +577,24 @@ function toInt16View(audio) {
577
577
  }
578
578
  const defaultStreamingUrl = "wss://streaming.assemblyai.com/v3/ws";
579
579
  const terminateSessionMessage = `{"type":"Terminate"}`;
580
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1000;
581
+ const DEFAULT_MAX_CONNECTION_RETRIES = 2;
582
+ const DEFAULT_CONNECTION_RETRY_DELAY_MS = 500;
583
+ /**
584
+ * Close/error codes that signal a permanent client-side problem (auth,
585
+ * billing, malformed config). A retry would hit the same failure, so the
586
+ * connection is never retried on these.
587
+ */
588
+ const NON_RETRYABLE_CLOSE_CODES = new Set([
589
+ StreamingErrorType.BadSampleRate,
590
+ StreamingErrorType.AuthFailed,
591
+ StreamingErrorType.InsufficientFunds,
592
+ StreamingErrorType.FreeTierUser,
593
+ StreamingErrorType.BadSchema,
594
+ ]);
595
+ function isRetryableCloseCode(code) {
596
+ return code !== 1000 && !NON_RETRYABLE_CLOSE_CODES.has(code);
597
+ }
580
598
  /**
581
599
  * Per-send chunk cap in milliseconds for the dual-channel mixer. The streaming
582
600
  * server rejects audio messages longer than 1000 ms (`Input Duration Error`).
@@ -708,8 +726,12 @@ class StreamingTranscriber {
708
726
  searchParams.set("speech_model", this.params.speechModel.toString());
709
727
  }
710
728
  if (this.params.languageCode !== undefined) {
729
+ console.warn("[Deprecation Warning] `languageCode` is deprecated and will be removed in a future release. Please use `languageCodes` instead.");
711
730
  searchParams.set("language_code", this.params.languageCode);
712
731
  }
732
+ if (this.params.languageCodes !== undefined) {
733
+ searchParams.set("language_codes", JSON.stringify(this.params.languageCodes));
734
+ }
713
735
  if (this.params.languageDetection !== undefined) {
714
736
  searchParams.set("language_detection", this.params.languageDetection.toString());
715
737
  }
@@ -783,12 +805,85 @@ class StreamingTranscriber {
783
805
  on(event, listener) {
784
806
  this.listeners[event] = listener;
785
807
  }
808
+ /**
809
+ * Open the streaming session.
810
+ *
811
+ * Resolves with the server's `Begin` event once the handshake completes. A
812
+ * single attempt is bounded by `connectTimeout` (default 1000ms); transient
813
+ * failures (timeout, network drop, unexpected close) are retried up to
814
+ * `maxConnectionRetries` times (default 2), waiting `connectionRetryDelay`
815
+ * (default 500ms) between attempts. Permanent failures (auth, insufficient
816
+ * funds, malformed config) are not retried.
817
+ *
818
+ * Unlike previously, a failed connection now rejects this promise rather
819
+ * than only invoking the `error` listener — necessary for the caller (and
820
+ * the retry loop) to observe the failure.
821
+ */
786
822
  connect() {
787
- return new Promise((resolve) => {
823
+ return __awaiter(this, void 0, void 0, function* () {
824
+ var _a, _b;
788
825
  if (this.socket) {
789
826
  throw new Error("Already connected");
790
827
  }
828
+ const maxRetries = (_a = this.params.maxConnectionRetries) !== null && _a !== void 0 ? _a : DEFAULT_MAX_CONNECTION_RETRIES;
829
+ const retryDelay = (_b = this.params.connectionRetryDelay) !== null && _b !== void 0 ? _b : DEFAULT_CONNECTION_RETRY_DELAY_MS;
830
+ let lastError;
831
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
832
+ try {
833
+ return yield this.connectOnce();
834
+ }
835
+ catch (err) {
836
+ lastError = err;
837
+ const retryable = err.retryable === true;
838
+ if (!retryable || attempt === maxRetries) {
839
+ throw err;
840
+ }
841
+ console.warn(`Streaming connect attempt ${attempt + 1}/${maxRetries + 1} failed (${err.message}); retrying`);
842
+ if (retryDelay > 0) {
843
+ yield new Promise((resolve) => setTimeout(resolve, retryDelay));
844
+ }
845
+ }
846
+ }
847
+ // The loop above always returns or throws; this only satisfies the type
848
+ // checker that a value is produced on every path.
849
+ throw lastError !== null && lastError !== void 0 ? lastError : new Error("Failed to connect to streaming server");
850
+ });
851
+ }
852
+ connectOnce() {
853
+ return new Promise((resolve, reject) => {
854
+ var _a;
791
855
  const url = this.connectionUrl();
856
+ const timeoutMs = (_a = this.params.connectTimeout) !== null && _a !== void 0 ? _a : DEFAULT_CONNECT_TIMEOUT_MS;
857
+ // `settled` flips once this attempt has resolved (`Begin`) or rejected
858
+ // (timeout / pre-`Begin` close / error). Before it flips the socket
859
+ // handlers drive this promise; after it flips they revert to normal
860
+ // runtime dispatch (close / error / message listeners).
861
+ let settled = false;
862
+ let timer;
863
+ const failAttempt = (error) => {
864
+ if (settled)
865
+ return;
866
+ settled = true;
867
+ if (timer)
868
+ clearTimeout(timer);
869
+ this.discardPendingSocket();
870
+ reject(error);
871
+ };
872
+ const succeed = (begin) => {
873
+ if (settled)
874
+ return;
875
+ settled = true;
876
+ if (timer)
877
+ clearTimeout(timer);
878
+ resolve(begin);
879
+ };
880
+ if (timeoutMs > 0) {
881
+ timer = setTimeout(() => {
882
+ const err = new StreamingError(`Streaming connection timed out after ${timeoutMs}ms`);
883
+ err.retryable = true;
884
+ failAttempt(err);
885
+ }, timeoutMs);
886
+ }
792
887
  if (this.token) {
793
888
  this.socket = factory(url.toString());
794
889
  }
@@ -806,6 +901,15 @@ class StreamingTranscriber {
806
901
  reason = StreamingErrorMessages[code];
807
902
  }
808
903
  }
904
+ // A close before `Begin` is a failed connection attempt — reject so
905
+ // connect() can retry (or surface a permanent failure).
906
+ if (!settled) {
907
+ const err = new StreamingError(reason || `Streaming connection closed (code=${code})`);
908
+ err.code = code;
909
+ err.retryable = isRetryableCloseCode(code);
910
+ failAttempt(err);
911
+ return;
912
+ }
809
913
  // Stop the flush timer when the socket is gone (server-initiated close,
810
914
  // network drop, etc.) — otherwise subsequent ticks call send() on a
811
915
  // closed socket and spam the error listener.
@@ -816,11 +920,15 @@ class StreamingTranscriber {
816
920
  (_b = (_a = this.listeners).close) === null || _b === void 0 ? void 0 : _b.call(_a, code, reason);
817
921
  };
818
922
  this.socket.onerror = (event) => {
819
- var _a, _b, _c, _d;
820
- if (event.error)
821
- (_b = (_a = this.listeners).error) === null || _b === void 0 ? void 0 : _b.call(_a, event.error);
822
- else
823
- (_d = (_c = this.listeners).error) === null || _d === void 0 ? void 0 : _d.call(_c, new Error(event.message));
923
+ var _a, _b, _c;
924
+ const error = (_a = event.error) !== null && _a !== void 0 ? _a : new Error(event.message);
925
+ // A socket error before `Begin` is a failed attempt reject/retry.
926
+ if (!settled) {
927
+ error.retryable = true;
928
+ failAttempt(error);
929
+ return;
930
+ }
931
+ (_c = (_b = this.listeners).error) === null || _c === void 0 ? void 0 : _c.call(_b, error);
824
932
  };
825
933
  this.socket.onmessage = ({ data }) => {
826
934
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
@@ -828,15 +936,23 @@ class StreamingTranscriber {
828
936
  if ("error" in message) {
829
937
  const err = new StreamingError(message.error);
830
938
  if ("error_code" in message) {
831
- err.code =
832
- message.error_code;
939
+ err.code = message.error_code;
940
+ }
941
+ // A server error frame before `Begin` fails the attempt; the code
942
+ // decides whether a retry is worthwhile.
943
+ if (!settled) {
944
+ const attemptErr = err;
945
+ attemptErr.retryable =
946
+ err.code === undefined ? true : isRetryableCloseCode(err.code);
947
+ failAttempt(attemptErr);
948
+ return;
833
949
  }
834
950
  (_b = (_a = this.listeners).error) === null || _b === void 0 ? void 0 : _b.call(_a, err);
835
951
  return;
836
952
  }
837
953
  switch (message.type) {
838
954
  case "Begin": {
839
- resolve(message);
955
+ succeed(message);
840
956
  (_d = (_c = this.listeners).open) === null || _d === void 0 ? void 0 : _d.call(_c, message);
841
957
  break;
842
958
  }
@@ -883,6 +999,20 @@ class StreamingTranscriber {
883
999
  };
884
1000
  });
885
1001
  }
1002
+ /** Tear down a half-open socket from a failed connection attempt. */
1003
+ discardPendingSocket() {
1004
+ if (!this.socket)
1005
+ return;
1006
+ try {
1007
+ if (this.socket.removeAllListeners)
1008
+ this.socket.removeAllListeners();
1009
+ this.socket.close();
1010
+ }
1011
+ catch (_a) {
1012
+ // Best-effort cleanup; a half-open socket may throw on close.
1013
+ }
1014
+ this.socket = undefined;
1015
+ }
886
1016
  /**
887
1017
  * Returns a WritableStream that pumps PCM chunks into `sendAudio`. Single-channel
888
1018
  * only — in dual-channel mode use `sendAudio(pcm, { channel })` directly, since
@@ -1156,6 +1286,16 @@ class StreamingTranscriber {
1156
1286
  };
1157
1287
  this.send(JSON.stringify(message));
1158
1288
  }
1289
+ /**
1290
+ * Reset the server's inactivity timer. Only needed when the session was
1291
+ * created with `inactivityTimeout` and no audio is being sent.
1292
+ */
1293
+ keepAlive() {
1294
+ const message = {
1295
+ type: "KeepAlive",
1296
+ };
1297
+ this.send(JSON.stringify(message));
1298
+ }
1159
1299
  send(data) {
1160
1300
  if (!this.socket || this.socket.readyState !== this.socket.OPEN) {
1161
1301
  throw new Error("Socket is not open for communication");