assemblyai 4.34.5 → 4.35.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.
@@ -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`).
@@ -739,12 +757,81 @@ class StreamingTranscriber {
739
757
  on(event, listener) {
740
758
  this.listeners[event] = listener;
741
759
  }
742
- connect() {
743
- return new Promise((resolve) => {
744
- if (this.socket) {
745
- throw new Error("Already connected");
760
+ /**
761
+ * Open the streaming session.
762
+ *
763
+ * Resolves with the server's `Begin` event once the handshake completes. A
764
+ * single attempt is bounded by `connectTimeout` (default 1000ms); transient
765
+ * failures (timeout, network drop, unexpected close) are retried up to
766
+ * `maxConnectionRetries` times (default 2), waiting `connectionRetryDelay`
767
+ * (default 500ms) between attempts. Permanent failures (auth, insufficient
768
+ * funds, malformed config) are not retried.
769
+ *
770
+ * Unlike previously, a failed connection now rejects this promise rather
771
+ * than only invoking the `error` listener — necessary for the caller (and
772
+ * the retry loop) to observe the failure.
773
+ */
774
+ async connect() {
775
+ if (this.socket) {
776
+ throw new Error("Already connected");
777
+ }
778
+ const maxRetries = this.params.maxConnectionRetries ?? DEFAULT_MAX_CONNECTION_RETRIES;
779
+ const retryDelay = this.params.connectionRetryDelay ?? DEFAULT_CONNECTION_RETRY_DELAY_MS;
780
+ let lastError;
781
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
782
+ try {
783
+ return await this.connectOnce();
784
+ }
785
+ catch (err) {
786
+ lastError = err;
787
+ const retryable = err.retryable === true;
788
+ if (!retryable || attempt === maxRetries) {
789
+ throw err;
790
+ }
791
+ console.warn(`Streaming connect attempt ${attempt + 1}/${maxRetries + 1} failed (${err.message}); retrying`);
792
+ if (retryDelay > 0) {
793
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
794
+ }
746
795
  }
796
+ }
797
+ // The loop above always returns or throws; this only satisfies the type
798
+ // checker that a value is produced on every path.
799
+ throw lastError ?? new Error("Failed to connect to streaming server");
800
+ }
801
+ connectOnce() {
802
+ return new Promise((resolve, reject) => {
747
803
  const url = this.connectionUrl();
804
+ const timeoutMs = this.params.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT_MS;
805
+ // `settled` flips once this attempt has resolved (`Begin`) or rejected
806
+ // (timeout / pre-`Begin` close / error). Before it flips the socket
807
+ // handlers drive this promise; after it flips they revert to normal
808
+ // runtime dispatch (close / error / message listeners).
809
+ let settled = false;
810
+ let timer;
811
+ const failAttempt = (error) => {
812
+ if (settled)
813
+ return;
814
+ settled = true;
815
+ if (timer)
816
+ clearTimeout(timer);
817
+ this.discardPendingSocket();
818
+ reject(error);
819
+ };
820
+ const succeed = (begin) => {
821
+ if (settled)
822
+ return;
823
+ settled = true;
824
+ if (timer)
825
+ clearTimeout(timer);
826
+ resolve(begin);
827
+ };
828
+ if (timeoutMs > 0) {
829
+ timer = setTimeout(() => {
830
+ const err = new StreamingError(`Streaming connection timed out after ${timeoutMs}ms`);
831
+ err.retryable = true;
832
+ failAttempt(err);
833
+ }, timeoutMs);
834
+ }
748
835
  if (this.token) {
749
836
  this.socket = factory(url.toString());
750
837
  }
@@ -765,6 +852,15 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
765
852
  reason = StreamingErrorMessages[code];
766
853
  }
767
854
  }
855
+ // A close before `Begin` is a failed connection attempt — reject so
856
+ // connect() can retry (or surface a permanent failure).
857
+ if (!settled) {
858
+ const err = new StreamingError(reason || `Streaming connection closed (code=${code})`);
859
+ err.code = code;
860
+ err.retryable = isRetryableCloseCode(code);
861
+ failAttempt(err);
862
+ return;
863
+ }
768
864
  // Stop the flush timer when the socket is gone (server-initiated close,
769
865
  // network drop, etc.) — otherwise subsequent ticks call send() on a
770
866
  // closed socket and spam the error listener.
@@ -775,25 +871,37 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
775
871
  this.listeners.close?.(code, reason);
776
872
  };
777
873
  this.socket.onerror = (event) => {
778
- if (event.error)
779
- this.listeners.error?.(event.error);
780
- else
781
- this.listeners.error?.(new Error(event.message));
874
+ const error = event.error ?? new Error(event.message);
875
+ // A socket error before `Begin` is a failed attempt → reject/retry.
876
+ if (!settled) {
877
+ error.retryable = true;
878
+ failAttempt(error);
879
+ return;
880
+ }
881
+ this.listeners.error?.(error);
782
882
  };
783
883
  this.socket.onmessage = ({ data }) => {
784
884
  const message = JSON.parse(data.toString());
785
885
  if ("error" in message) {
786
886
  const err = new StreamingError(message.error);
787
887
  if ("error_code" in message) {
788
- err.code =
789
- message.error_code;
888
+ err.code = message.error_code;
889
+ }
890
+ // A server error frame before `Begin` fails the attempt; the code
891
+ // decides whether a retry is worthwhile.
892
+ if (!settled) {
893
+ const attemptErr = err;
894
+ attemptErr.retryable =
895
+ err.code === undefined ? true : isRetryableCloseCode(err.code);
896
+ failAttempt(attemptErr);
897
+ return;
790
898
  }
791
899
  this.listeners.error?.(err);
792
900
  return;
793
901
  }
794
902
  switch (message.type) {
795
903
  case "Begin": {
796
- resolve(message);
904
+ succeed(message);
797
905
  this.listeners.open?.(message);
798
906
  break;
799
907
  }
@@ -840,6 +948,20 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
840
948
  };
841
949
  });
842
950
  }
951
+ /** Tear down a half-open socket from a failed connection attempt. */
952
+ discardPendingSocket() {
953
+ if (!this.socket)
954
+ return;
955
+ try {
956
+ if (this.socket.removeAllListeners)
957
+ this.socket.removeAllListeners();
958
+ this.socket.close();
959
+ }
960
+ catch {
961
+ // Best-effort cleanup; a half-open socket may throw on close.
962
+ }
963
+ this.socket = undefined;
964
+ }
843
965
  /**
844
966
  * Returns a WritableStream that pumps PCM chunks into `sendAudio`. Single-channel
845
967
  * only — in dual-channel mode use `sendAudio(pcm, { channel })` directly, since
@@ -1113,6 +1235,16 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
1113
1235
  };
1114
1236
  this.send(JSON.stringify(message));
1115
1237
  }
1238
+ /**
1239
+ * Reset the server's inactivity timer. Only needed when the session was
1240
+ * created with `inactivityTimeout` and no audio is being sent.
1241
+ */
1242
+ keepAlive() {
1243
+ const message = {
1244
+ type: "KeepAlive",
1245
+ };
1246
+ this.send(JSON.stringify(message));
1247
+ }
1116
1248
  send(data) {
1117
1249
  if (!this.socket || this.socket.readyState !== this.socket.OPEN) {
1118
1250
  throw new Error("Socket is not open for communication");
@@ -1168,7 +1300,7 @@ if (typeof navigator !== "undefined" && navigator.userAgent) {
1168
1300
  defaultUserAgentString += navigator.userAgent;
1169
1301
  }
1170
1302
  const defaultUserAgent = {
1171
- sdk: { name: "JavaScript", version: "4.34.5" },
1303
+ sdk: { name: "JavaScript", version: "4.35.0" },
1172
1304
  };
1173
1305
  if (typeof process !== "undefined") {
1174
1306
  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`).
@@ -785,12 +803,85 @@ class StreamingTranscriber {
785
803
  on(event, listener) {
786
804
  this.listeners[event] = listener;
787
805
  }
806
+ /**
807
+ * Open the streaming session.
808
+ *
809
+ * Resolves with the server's `Begin` event once the handshake completes. A
810
+ * single attempt is bounded by `connectTimeout` (default 1000ms); transient
811
+ * failures (timeout, network drop, unexpected close) are retried up to
812
+ * `maxConnectionRetries` times (default 2), waiting `connectionRetryDelay`
813
+ * (default 500ms) between attempts. Permanent failures (auth, insufficient
814
+ * funds, malformed config) are not retried.
815
+ *
816
+ * Unlike previously, a failed connection now rejects this promise rather
817
+ * than only invoking the `error` listener — necessary for the caller (and
818
+ * the retry loop) to observe the failure.
819
+ */
788
820
  connect() {
789
- return new Promise((resolve) => {
821
+ return __awaiter(this, void 0, void 0, function* () {
822
+ var _a, _b;
790
823
  if (this.socket) {
791
824
  throw new Error("Already connected");
792
825
  }
826
+ const maxRetries = (_a = this.params.maxConnectionRetries) !== null && _a !== void 0 ? _a : DEFAULT_MAX_CONNECTION_RETRIES;
827
+ const retryDelay = (_b = this.params.connectionRetryDelay) !== null && _b !== void 0 ? _b : DEFAULT_CONNECTION_RETRY_DELAY_MS;
828
+ let lastError;
829
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
830
+ try {
831
+ return yield this.connectOnce();
832
+ }
833
+ catch (err) {
834
+ lastError = err;
835
+ const retryable = err.retryable === true;
836
+ if (!retryable || attempt === maxRetries) {
837
+ throw err;
838
+ }
839
+ console.warn(`Streaming connect attempt ${attempt + 1}/${maxRetries + 1} failed (${err.message}); retrying`);
840
+ if (retryDelay > 0) {
841
+ yield new Promise((resolve) => setTimeout(resolve, retryDelay));
842
+ }
843
+ }
844
+ }
845
+ // The loop above always returns or throws; this only satisfies the type
846
+ // checker that a value is produced on every path.
847
+ throw lastError !== null && lastError !== void 0 ? lastError : new Error("Failed to connect to streaming server");
848
+ });
849
+ }
850
+ connectOnce() {
851
+ return new Promise((resolve, reject) => {
852
+ var _a;
793
853
  const url = this.connectionUrl();
854
+ const timeoutMs = (_a = this.params.connectTimeout) !== null && _a !== void 0 ? _a : DEFAULT_CONNECT_TIMEOUT_MS;
855
+ // `settled` flips once this attempt has resolved (`Begin`) or rejected
856
+ // (timeout / pre-`Begin` close / error). Before it flips the socket
857
+ // handlers drive this promise; after it flips they revert to normal
858
+ // runtime dispatch (close / error / message listeners).
859
+ let settled = false;
860
+ let timer;
861
+ const failAttempt = (error) => {
862
+ if (settled)
863
+ return;
864
+ settled = true;
865
+ if (timer)
866
+ clearTimeout(timer);
867
+ this.discardPendingSocket();
868
+ reject(error);
869
+ };
870
+ const succeed = (begin) => {
871
+ if (settled)
872
+ return;
873
+ settled = true;
874
+ if (timer)
875
+ clearTimeout(timer);
876
+ resolve(begin);
877
+ };
878
+ if (timeoutMs > 0) {
879
+ timer = setTimeout(() => {
880
+ const err = new StreamingError(`Streaming connection timed out after ${timeoutMs}ms`);
881
+ err.retryable = true;
882
+ failAttempt(err);
883
+ }, timeoutMs);
884
+ }
794
885
  if (this.token) {
795
886
  this.socket = factory(url.toString());
796
887
  }
@@ -808,6 +899,15 @@ class StreamingTranscriber {
808
899
  reason = StreamingErrorMessages[code];
809
900
  }
810
901
  }
902
+ // A close before `Begin` is a failed connection attempt — reject so
903
+ // connect() can retry (or surface a permanent failure).
904
+ if (!settled) {
905
+ const err = new StreamingError(reason || `Streaming connection closed (code=${code})`);
906
+ err.code = code;
907
+ err.retryable = isRetryableCloseCode(code);
908
+ failAttempt(err);
909
+ return;
910
+ }
811
911
  // Stop the flush timer when the socket is gone (server-initiated close,
812
912
  // network drop, etc.) — otherwise subsequent ticks call send() on a
813
913
  // closed socket and spam the error listener.
@@ -818,11 +918,15 @@ class StreamingTranscriber {
818
918
  (_b = (_a = this.listeners).close) === null || _b === void 0 ? void 0 : _b.call(_a, code, reason);
819
919
  };
820
920
  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));
921
+ var _a, _b, _c;
922
+ const error = (_a = event.error) !== null && _a !== void 0 ? _a : new Error(event.message);
923
+ // A socket error before `Begin` is a failed attempt reject/retry.
924
+ if (!settled) {
925
+ error.retryable = true;
926
+ failAttempt(error);
927
+ return;
928
+ }
929
+ (_c = (_b = this.listeners).error) === null || _c === void 0 ? void 0 : _c.call(_b, error);
826
930
  };
827
931
  this.socket.onmessage = ({ data }) => {
828
932
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
@@ -830,15 +934,23 @@ class StreamingTranscriber {
830
934
  if ("error" in message) {
831
935
  const err = new StreamingError(message.error);
832
936
  if ("error_code" in message) {
833
- err.code =
834
- message.error_code;
937
+ err.code = message.error_code;
938
+ }
939
+ // A server error frame before `Begin` fails the attempt; the code
940
+ // decides whether a retry is worthwhile.
941
+ if (!settled) {
942
+ const attemptErr = err;
943
+ attemptErr.retryable =
944
+ err.code === undefined ? true : isRetryableCloseCode(err.code);
945
+ failAttempt(attemptErr);
946
+ return;
835
947
  }
836
948
  (_b = (_a = this.listeners).error) === null || _b === void 0 ? void 0 : _b.call(_a, err);
837
949
  return;
838
950
  }
839
951
  switch (message.type) {
840
952
  case "Begin": {
841
- resolve(message);
953
+ succeed(message);
842
954
  (_d = (_c = this.listeners).open) === null || _d === void 0 ? void 0 : _d.call(_c, message);
843
955
  break;
844
956
  }
@@ -885,6 +997,20 @@ class StreamingTranscriber {
885
997
  };
886
998
  });
887
999
  }
1000
+ /** Tear down a half-open socket from a failed connection attempt. */
1001
+ discardPendingSocket() {
1002
+ if (!this.socket)
1003
+ return;
1004
+ try {
1005
+ if (this.socket.removeAllListeners)
1006
+ this.socket.removeAllListeners();
1007
+ this.socket.close();
1008
+ }
1009
+ catch (_a) {
1010
+ // Best-effort cleanup; a half-open socket may throw on close.
1011
+ }
1012
+ this.socket = undefined;
1013
+ }
888
1014
  /**
889
1015
  * Returns a WritableStream that pumps PCM chunks into `sendAudio`. Single-channel
890
1016
  * only — in dual-channel mode use `sendAudio(pcm, { channel })` directly, since
@@ -1158,6 +1284,16 @@ class StreamingTranscriber {
1158
1284
  };
1159
1285
  this.send(JSON.stringify(message));
1160
1286
  }
1287
+ /**
1288
+ * Reset the server's inactivity timer. Only needed when the session was
1289
+ * created with `inactivityTimeout` and no audio is being sent.
1290
+ */
1291
+ keepAlive() {
1292
+ const message = {
1293
+ type: "KeepAlive",
1294
+ };
1295
+ this.send(JSON.stringify(message));
1296
+ }
1161
1297
  send(data) {
1162
1298
  if (!this.socket || this.socket.readyState !== this.socket.OPEN) {
1163
1299
  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`).
@@ -783,12 +801,85 @@ class StreamingTranscriber {
783
801
  on(event, listener) {
784
802
  this.listeners[event] = listener;
785
803
  }
804
+ /**
805
+ * Open the streaming session.
806
+ *
807
+ * Resolves with the server's `Begin` event once the handshake completes. A
808
+ * single attempt is bounded by `connectTimeout` (default 1000ms); transient
809
+ * failures (timeout, network drop, unexpected close) are retried up to
810
+ * `maxConnectionRetries` times (default 2), waiting `connectionRetryDelay`
811
+ * (default 500ms) between attempts. Permanent failures (auth, insufficient
812
+ * funds, malformed config) are not retried.
813
+ *
814
+ * Unlike previously, a failed connection now rejects this promise rather
815
+ * than only invoking the `error` listener — necessary for the caller (and
816
+ * the retry loop) to observe the failure.
817
+ */
786
818
  connect() {
787
- return new Promise((resolve) => {
819
+ return __awaiter(this, void 0, void 0, function* () {
820
+ var _a, _b;
788
821
  if (this.socket) {
789
822
  throw new Error("Already connected");
790
823
  }
824
+ const maxRetries = (_a = this.params.maxConnectionRetries) !== null && _a !== void 0 ? _a : DEFAULT_MAX_CONNECTION_RETRIES;
825
+ const retryDelay = (_b = this.params.connectionRetryDelay) !== null && _b !== void 0 ? _b : DEFAULT_CONNECTION_RETRY_DELAY_MS;
826
+ let lastError;
827
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
828
+ try {
829
+ return yield this.connectOnce();
830
+ }
831
+ catch (err) {
832
+ lastError = err;
833
+ const retryable = err.retryable === true;
834
+ if (!retryable || attempt === maxRetries) {
835
+ throw err;
836
+ }
837
+ console.warn(`Streaming connect attempt ${attempt + 1}/${maxRetries + 1} failed (${err.message}); retrying`);
838
+ if (retryDelay > 0) {
839
+ yield new Promise((resolve) => setTimeout(resolve, retryDelay));
840
+ }
841
+ }
842
+ }
843
+ // The loop above always returns or throws; this only satisfies the type
844
+ // checker that a value is produced on every path.
845
+ throw lastError !== null && lastError !== void 0 ? lastError : new Error("Failed to connect to streaming server");
846
+ });
847
+ }
848
+ connectOnce() {
849
+ return new Promise((resolve, reject) => {
850
+ var _a;
791
851
  const url = this.connectionUrl();
852
+ const timeoutMs = (_a = this.params.connectTimeout) !== null && _a !== void 0 ? _a : DEFAULT_CONNECT_TIMEOUT_MS;
853
+ // `settled` flips once this attempt has resolved (`Begin`) or rejected
854
+ // (timeout / pre-`Begin` close / error). Before it flips the socket
855
+ // handlers drive this promise; after it flips they revert to normal
856
+ // runtime dispatch (close / error / message listeners).
857
+ let settled = false;
858
+ let timer;
859
+ const failAttempt = (error) => {
860
+ if (settled)
861
+ return;
862
+ settled = true;
863
+ if (timer)
864
+ clearTimeout(timer);
865
+ this.discardPendingSocket();
866
+ reject(error);
867
+ };
868
+ const succeed = (begin) => {
869
+ if (settled)
870
+ return;
871
+ settled = true;
872
+ if (timer)
873
+ clearTimeout(timer);
874
+ resolve(begin);
875
+ };
876
+ if (timeoutMs > 0) {
877
+ timer = setTimeout(() => {
878
+ const err = new StreamingError(`Streaming connection timed out after ${timeoutMs}ms`);
879
+ err.retryable = true;
880
+ failAttempt(err);
881
+ }, timeoutMs);
882
+ }
792
883
  if (this.token) {
793
884
  this.socket = factory(url.toString());
794
885
  }
@@ -806,6 +897,15 @@ class StreamingTranscriber {
806
897
  reason = StreamingErrorMessages[code];
807
898
  }
808
899
  }
900
+ // A close before `Begin` is a failed connection attempt — reject so
901
+ // connect() can retry (or surface a permanent failure).
902
+ if (!settled) {
903
+ const err = new StreamingError(reason || `Streaming connection closed (code=${code})`);
904
+ err.code = code;
905
+ err.retryable = isRetryableCloseCode(code);
906
+ failAttempt(err);
907
+ return;
908
+ }
809
909
  // Stop the flush timer when the socket is gone (server-initiated close,
810
910
  // network drop, etc.) — otherwise subsequent ticks call send() on a
811
911
  // closed socket and spam the error listener.
@@ -816,11 +916,15 @@ class StreamingTranscriber {
816
916
  (_b = (_a = this.listeners).close) === null || _b === void 0 ? void 0 : _b.call(_a, code, reason);
817
917
  };
818
918
  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));
919
+ var _a, _b, _c;
920
+ const error = (_a = event.error) !== null && _a !== void 0 ? _a : new Error(event.message);
921
+ // A socket error before `Begin` is a failed attempt reject/retry.
922
+ if (!settled) {
923
+ error.retryable = true;
924
+ failAttempt(error);
925
+ return;
926
+ }
927
+ (_c = (_b = this.listeners).error) === null || _c === void 0 ? void 0 : _c.call(_b, error);
824
928
  };
825
929
  this.socket.onmessage = ({ data }) => {
826
930
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
@@ -828,15 +932,23 @@ class StreamingTranscriber {
828
932
  if ("error" in message) {
829
933
  const err = new StreamingError(message.error);
830
934
  if ("error_code" in message) {
831
- err.code =
832
- message.error_code;
935
+ err.code = message.error_code;
936
+ }
937
+ // A server error frame before `Begin` fails the attempt; the code
938
+ // decides whether a retry is worthwhile.
939
+ if (!settled) {
940
+ const attemptErr = err;
941
+ attemptErr.retryable =
942
+ err.code === undefined ? true : isRetryableCloseCode(err.code);
943
+ failAttempt(attemptErr);
944
+ return;
833
945
  }
834
946
  (_b = (_a = this.listeners).error) === null || _b === void 0 ? void 0 : _b.call(_a, err);
835
947
  return;
836
948
  }
837
949
  switch (message.type) {
838
950
  case "Begin": {
839
- resolve(message);
951
+ succeed(message);
840
952
  (_d = (_c = this.listeners).open) === null || _d === void 0 ? void 0 : _d.call(_c, message);
841
953
  break;
842
954
  }
@@ -883,6 +995,20 @@ class StreamingTranscriber {
883
995
  };
884
996
  });
885
997
  }
998
+ /** Tear down a half-open socket from a failed connection attempt. */
999
+ discardPendingSocket() {
1000
+ if (!this.socket)
1001
+ return;
1002
+ try {
1003
+ if (this.socket.removeAllListeners)
1004
+ this.socket.removeAllListeners();
1005
+ this.socket.close();
1006
+ }
1007
+ catch (_a) {
1008
+ // Best-effort cleanup; a half-open socket may throw on close.
1009
+ }
1010
+ this.socket = undefined;
1011
+ }
886
1012
  /**
887
1013
  * Returns a WritableStream that pumps PCM chunks into `sendAudio`. Single-channel
888
1014
  * only — in dual-channel mode use `sendAudio(pcm, { channel })` directly, since
@@ -1156,6 +1282,16 @@ class StreamingTranscriber {
1156
1282
  };
1157
1283
  this.send(JSON.stringify(message));
1158
1284
  }
1285
+ /**
1286
+ * Reset the server's inactivity timer. Only needed when the session was
1287
+ * created with `inactivityTimeout` and no audio is being sent.
1288
+ */
1289
+ keepAlive() {
1290
+ const message = {
1291
+ type: "KeepAlive",
1292
+ };
1293
+ this.send(JSON.stringify(message));
1294
+ }
1159
1295
  send(data) {
1160
1296
  if (!this.socket || this.socket.readyState !== this.socket.OPEN) {
1161
1297
  throw new Error("Socket is not open for communication");