@effect-uai/inworld 0.17.0 → 0.19.0-dev-20261109183959

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 (48) hide show
  1. package/dist/InworldRealtimeSynthesizer.d.mts +1 -1
  2. package/dist/InworldRealtimeSynthesizer.mjs +2 -2
  3. package/dist/InworldRealtimeSynthesizer.mjs.map +1 -1
  4. package/dist/InworldRealtimeTranscriber.d.mts +1 -1
  5. package/dist/InworldRealtimeTranscriber.mjs +2 -2
  6. package/dist/InworldRealtimeTranscriber.mjs.map +1 -1
  7. package/dist/InworldSynthesizer.d.mts +1 -1
  8. package/dist/InworldSynthesizer.mjs +2 -2
  9. package/dist/InworldSynthesizer.mjs.map +1 -1
  10. package/dist/InworldTranscriber.d.mts +1 -1
  11. package/dist/InworldTranscriber.d.mts.map +1 -1
  12. package/dist/InworldTranscriber.mjs +5 -4
  13. package/dist/InworldTranscriber.mjs.map +1 -1
  14. package/dist/codec-BU4azW_k.d.mts.map +1 -1
  15. package/dist/codec.mjs +4 -3
  16. package/dist/codec.mjs.map +1 -1
  17. package/dist/dist-DHer1cql.mjs +1533 -0
  18. package/dist/dist-DHer1cql.mjs.map +1 -0
  19. package/dist/dist-DvYz71Uo.mjs +17472 -0
  20. package/dist/dist-DvYz71Uo.mjs.map +1 -0
  21. package/dist/realtimeStt.d.mts +6 -0
  22. package/dist/realtimeStt.d.mts.map +1 -1
  23. package/dist/realtimeStt.mjs +18 -19
  24. package/dist/realtimeStt.mjs.map +1 -1
  25. package/dist/realtimeStt.test.d.mts +1 -0
  26. package/dist/realtimeStt.test.mjs +83 -0
  27. package/dist/realtimeStt.test.mjs.map +1 -0
  28. package/dist/realtimeTts.d.mts +6 -0
  29. package/dist/realtimeTts.d.mts.map +1 -1
  30. package/dist/realtimeTts.mjs +18 -17
  31. package/dist/realtimeTts.mjs.map +1 -1
  32. package/dist/realtimeTts.test.d.mts +1 -0
  33. package/dist/realtimeTts.test.mjs +70 -0
  34. package/dist/realtimeTts.test.mjs.map +1 -0
  35. package/dist/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-EAsCxrDo.mjs} +2 -1
  36. package/dist/wsAuth.d.mts +1 -1
  37. package/dist/wsAuth.mjs.map +1 -1
  38. package/package.json +5 -5
  39. package/src/InworldRealtimeSynthesizer.ts +1 -1
  40. package/src/InworldRealtimeTranscriber.ts +1 -1
  41. package/src/InworldSynthesizer.ts +1 -1
  42. package/src/InworldTranscriber.ts +4 -3
  43. package/src/codec.ts +3 -2
  44. package/src/realtimeStt.test.ts +82 -0
  45. package/src/realtimeStt.ts +46 -36
  46. package/src/realtimeTts.test.ts +74 -0
  47. package/src/realtimeTts.ts +52 -36
  48. package/src/wsAuth.ts +1 -1
package/src/codec.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Effect, Encoding, Match, Redacted, Result } from "effect"
1
+ import { Effect, Match, Redacted, Result } from "effect"
2
+ import { Base64 } from "effect/encoding"
2
3
  import * as AiError from "@effect-uai/core/AiError"
3
4
  import type { AudioFormat } from "@effect-uai/core/Audio"
4
5
  import type { InworldAudioEncoding } from "./models.js"
@@ -82,7 +83,7 @@ export const defaultFormat: AudioFormat = {
82
83
  // ---------------------------------------------------------------------------
83
84
 
84
85
  export const decodeAudioContent = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
85
- Result.match(Encoding.decodeBase64(b64), {
86
+ Result.match(Base64.decode(b64), {
86
87
  onSuccess: Effect.succeed,
87
88
  onFailure: (cause) =>
88
89
  Effect.fail(
@@ -0,0 +1,82 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Exit, Fiber, Redacted, Stream } from "effect"
3
+ import { expect } from "vitest"
4
+ import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
5
+ import { streamTranscription } from "./realtimeStt.js"
6
+
7
+ const pcm = { container: "raw", encoding: "pcm_s16le", sampleRate: 16000, channels: 1 } as const
8
+
9
+ const request = { model: "inworld/inworld-stt-1", inputFormat: pcm }
10
+
11
+ const transcribing = FakeWebSocket.make({
12
+ greeting: [
13
+ { result: { speechStarted: { startTimeMs: 1500 } } },
14
+ { result: { transcription: { transcript: "hel", isFinal: false } } },
15
+ { result: { transcription: { transcript: "hello", isFinal: true } } },
16
+ ],
17
+ })
18
+
19
+ /** Run a transcription against `server`, closing it, if asked, once the frames are in. */
20
+ const transcribe = <E>(
21
+ server: FakeWebSocket.FakeWebSocketServer,
22
+ audioIn: Stream.Stream<Uint8Array, E>,
23
+ close?: { readonly code: number; readonly afterSent?: number },
24
+ ) =>
25
+ Effect.gen(function* () {
26
+ const run = streamTranscription({
27
+ apiKey: Redacted.make("test-key"),
28
+ webSocket: server.connect,
29
+ })
30
+ const collector = yield* Effect.forkChild(Effect.exit(Stream.runCollect(run(audioIn, request))))
31
+ if (close !== undefined) {
32
+ yield* server.greeted
33
+ yield* server.awaitSent(close.afterSent ?? 0)
34
+ yield* server.close(close.code)
35
+ }
36
+ return yield* Fiber.join(collector)
37
+ })
38
+
39
+ describe("Inworld Realtime STT socket", () => {
40
+ it.live("sends the config before any audio, and ends on a clean close", () =>
41
+ Effect.gen(function* () {
42
+ const server = yield* transcribing
43
+ const exit = yield* transcribe(server, Stream.make(new Uint8Array([1, 2])), {
44
+ code: 1000,
45
+ afterSent: 4,
46
+ })
47
+
48
+ expect(exit).toEqual(
49
+ Exit.succeed([
50
+ { _tag: "speech-started", atSeconds: 1.5 },
51
+ { _tag: "partial", text: "hel" },
52
+ { _tag: "final", text: "hello" },
53
+ ]),
54
+ )
55
+ const sent = (yield* server.sent) as ReadonlyArray<Record<string, unknown>>
56
+ expect(sent.map((f) => Object.keys(f)[0])).toEqual([
57
+ "transcribeConfig",
58
+ "audioChunk",
59
+ "endTurn",
60
+ "closeStream",
61
+ ])
62
+ }).pipe(Effect.scoped),
63
+ )
64
+
65
+ it.live("fails rather than ending when the connection drops", () =>
66
+ Effect.gen(function* () {
67
+ const server = yield* transcribing
68
+ const exit = yield* transcribe(server, Stream.never, { code: 1011 })
69
+
70
+ expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("Unavailable")
71
+ }).pipe(Effect.scoped),
72
+ )
73
+
74
+ it.live("fails with the audio input's own error", () =>
75
+ Effect.gen(function* () {
76
+ const server = yield* transcribing
77
+ const exit = yield* transcribe(server, Stream.fail("microphone unplugged"))
78
+
79
+ expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("microphone unplugged")
80
+ }).pipe(Effect.scoped),
81
+ )
82
+ })
@@ -18,17 +18,27 @@
18
18
  * Inworld's STT WS sends audio as base64 inside JSON (NOT binary frames),
19
19
  * matching the rest of the Inworld API style.
20
20
  */
21
- import { Cause, Effect, Encoding, Match, Queue, Redacted, Schema, Stream } from "effect"
22
- import * as Socket from "effect/unstable/socket/Socket"
21
+ import { Effect, Match, Option, Predicate, Redacted, Schema, Stream } from "effect"
22
+ import { Base64 } from "effect/encoding"
23
+ import * as Socket from "effect/socket/Socket"
23
24
  import * as AiError from "@effect-uai/core/AiError"
24
25
  import type { AudioFormat } from "@effect-uai/core/Audio"
25
26
  import * as Capabilities from "@effect-uai/core/Capabilities"
26
27
  import * as JSONL from "@effect-uai/core/JSONL"
27
28
  import type { TranscriptEvent, WordTimestamp } from "@effect-uai/core/Transcript"
28
29
  import type { CommonStreamTranscribeRequest } from "@effect-uai/core/Transcriber"
30
+ import * as WebSocketSession from "@effect-uai/core/WebSocketSession"
29
31
  import { authedWsConstructor } from "./wsAuth.js"
30
32
 
31
- export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
33
+ export type Config = {
34
+ readonly apiKey: Redacted.Redacted
35
+ readonly baseUrl?: string
36
+ /**
37
+ * Build the socket yourself, for a proxy or an in-memory transport. The
38
+ * default attaches the Basic auth header through the `ws` package.
39
+ */
40
+ readonly webSocket?: Socket.WebSocketConstructor["Service"]
41
+ }
32
42
 
33
43
  // ---------------------------------------------------------------------------
34
44
  // AudioFormat → `audioEncoding` slug for Inworld STT
@@ -85,7 +95,7 @@ const configFrame = (encoding: WireEncoding, request: CommonStreamTranscribeRequ
85
95
  })
86
96
 
87
97
  const audioChunkFrame = (bytes: Uint8Array) =>
88
- JSON.stringify({ audioChunk: { content: Encoding.encodeBase64(bytes) } })
98
+ JSON.stringify({ audioChunk: { content: Base64.encode(bytes) } })
89
99
 
90
100
  const endTurnFrame = JSON.stringify({ endTurn: {} })
91
101
  const closeStreamFrame = JSON.stringify({ closeStream: {} })
@@ -177,14 +187,13 @@ export const wireToEvent = (frame: typeof ServerFrame.Type): TranscriptEvent | u
177
187
  return undefined
178
188
  }
179
189
 
180
- const handleServerMessage = (queue: Queue.Queue<TranscriptEvent, Cause.Done>) => (raw: string) =>
190
+ /** One raw text frame to at most one event; unknown or malformed frames yield `undefined`. */
191
+ const frameToEvent = (raw: string): Effect.Effect<TranscriptEvent | undefined> =>
181
192
  Effect.gen(function* () {
182
193
  const json = yield* JSONL.parseSafe(raw)
183
- if (json === undefined) return
194
+ if (json === undefined) return undefined
184
195
  const decoded = yield* decodeServerFrame(json).pipe(Effect.option)
185
- if (decoded._tag === "None") return
186
- const event = wireToEvent(decoded.value)
187
- if (event !== undefined) yield* Queue.offer(queue, event)
196
+ return Option.match(decoded, { onNone: () => undefined, onSome: wireToEvent })
188
197
  })
189
198
 
190
199
  // ---------------------------------------------------------------------------
@@ -206,32 +215,33 @@ export const streamTranscription =
206
215
  reason: "Inworld STT has no free-form prompt field; bias via `biasingTerms`.",
207
216
  })
208
217
  const encoding = yield* inputFormatToWire(request.inputFormat)
209
- const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg), {
210
- // Effect's Socket treats all close codes as errors by default —
211
- // whitelist standard clean-close codes (1000 / 1001 / 1005).
212
- closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
213
- }).pipe(Effect.provideService(Socket.WebSocketConstructor, authedWsConstructor(cfg.apiKey)))
214
- const queue = yield* Queue.bounded<TranscriptEvent, Cause.Done>(64)
215
- const write = yield* socket.writer
216
-
217
- // Writer: config → drain audio → endTurn → closeStream. Inworld
218
- // emits `result.transcription` partials in real-time via VAD;
219
- // `endTurn` is sent on input-stream end to flush any tail audio
220
- // into a final transcript, then `closeStream` for graceful close.
221
- yield* Effect.gen(function* () {
222
- yield* write(configFrame(encoding, request))
223
- yield* Stream.runForEach(audioIn, (bytes) => write(audioChunkFrame(bytes)))
224
- yield* write(endTurnFrame)
225
- yield* write(closeStreamFrame)
226
- }).pipe(Effect.ignore, Effect.forkScoped)
227
-
228
- // `Queue.end` flushes pending events then fails the next take with
229
- // `Done` — clean stream end. `Queue.shutdown` would CLEAR queued
230
- // items and interrupt pending takes (wrong for graceful teardown).
231
- yield* socket
232
- .runString(handleServerMessage(queue))
233
- .pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
234
-
235
- return Stream.fromQueue(queue)
218
+ const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg)).pipe(
219
+ Effect.provideService(
220
+ Socket.WebSocketConstructor,
221
+ cfg.webSocket ?? authedWsConstructor(cfg.apiKey),
222
+ ),
223
+ )
224
+ const { write } = yield* socket.writer
225
+ const asAiError = WebSocketSession.toAiError("inworld")
226
+
227
+ const events = Stream.fromPull(Socket.readerString(socket)).pipe(
228
+ Stream.scoped,
229
+ Stream.catchIf(WebSocketSession.isCleanClose, () => Stream.empty),
230
+ Stream.mapError(asAiError),
231
+ Stream.mapEffect(frameToEvent),
232
+ Stream.filter(Predicate.isNotUndefined),
233
+ )
234
+
235
+ // `endTurn` flushes tail audio into a final transcript before `closeStream`.
236
+ const outgoing = Stream.concat(
237
+ Stream.make(configFrame(encoding, request)),
238
+ Stream.concat(
239
+ Stream.map(audioIn, audioChunkFrame),
240
+ Stream.make(endTurnFrame, closeStreamFrame),
241
+ ),
242
+ ).pipe(Stream.mapEffect((frame) => Effect.mapError(write(frame), asAiError)))
243
+
244
+ // The server's close ends the transcript; the audio running out does not.
245
+ return Stream.merge(events, Stream.drain(outgoing), { haltStrategy: "left" })
236
246
  }),
237
247
  )
@@ -0,0 +1,74 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Effect, Exit, Fiber, Redacted, Stream } from "effect"
3
+ import { Base64 } from "effect/encoding"
4
+ import { expect } from "vitest"
5
+ import * as FakeWebSocket from "@effect-uai/core/testing/FakeWebSocket"
6
+ import { streamSynthesis } from "./realtimeTts.js"
7
+
8
+ const request = { model: "inworld-tts-2", voiceId: "Ashley" } as const
9
+
10
+ const synthesizing = FakeWebSocket.make({
11
+ greeting: [
12
+ { result: { contextCreated: {} } },
13
+ { result: { audioChunk: { audioContent: Base64.encode(new Uint8Array([1, 2, 3])) } } },
14
+ { error: { message: "transient" } },
15
+ { result: { audioChunk: { audioContent: "" } } },
16
+ { result: { contextClosed: {} } },
17
+ ],
18
+ })
19
+
20
+ /** Run a synthesis against `server`, closing it, if asked, once the frames are in. */
21
+ const synthesize = <E>(
22
+ server: FakeWebSocket.FakeWebSocketServer,
23
+ textIn: Stream.Stream<string, E>,
24
+ close?: { readonly code: number; readonly afterSent?: number },
25
+ ) =>
26
+ Effect.gen(function* () {
27
+ const run = streamSynthesis({ apiKey: Redacted.make("test-key"), webSocket: server.connect })
28
+ const collector = yield* Effect.forkChild(Effect.exit(Stream.runCollect(run(textIn, request))))
29
+ if (close !== undefined) {
30
+ yield* server.greeted
31
+ yield* server.awaitSent(close.afterSent ?? 0)
32
+ yield* server.close(close.code)
33
+ }
34
+ return yield* Fiber.join(collector)
35
+ })
36
+
37
+ describe("Inworld Realtime TTS socket", () => {
38
+ it.live("creates the context before any text, and ends on a clean close", () =>
39
+ Effect.gen(function* () {
40
+ const server = yield* synthesizing
41
+ const exit = yield* synthesize(server, Stream.make("Hello", "", " world"), {
42
+ code: 1000,
43
+ afterSent: 4,
44
+ })
45
+
46
+ expect(exit).toEqual(Exit.succeed([{ bytes: new Uint8Array([1, 2, 3]) }]))
47
+ const sent = (yield* server.sent) as ReadonlyArray<Record<string, unknown>>
48
+ expect(sent.map((f) => Object.keys(f)[0])).toEqual([
49
+ "create",
50
+ "send_text",
51
+ "send_text",
52
+ "close_context",
53
+ ])
54
+ }).pipe(Effect.scoped),
55
+ )
56
+
57
+ it.live("fails rather than ending when the connection drops", () =>
58
+ Effect.gen(function* () {
59
+ const server = yield* synthesizing
60
+ const exit = yield* synthesize(server, Stream.never, { code: 1011 })
61
+
62
+ expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("Unavailable")
63
+ }).pipe(Effect.scoped),
64
+ )
65
+
66
+ it.live("fails with the text input's own error", () =>
67
+ Effect.gen(function* () {
68
+ const server = yield* synthesizing
69
+ const exit = yield* synthesize(server, Stream.fail("upstream llm failed"))
70
+
71
+ expect(Exit.isFailure(exit) && JSON.stringify(exit.cause)).toContain("upstream llm failed")
72
+ }).pipe(Effect.scoped),
73
+ )
74
+ })
@@ -19,17 +19,26 @@
19
19
  * Multi-context (`contextId`) is not surfaced here — one logical
20
20
  * utterance per call.
21
21
  */
22
- import { Cause, Effect, Queue, Redacted, Schema, Stream } from "effect"
23
- import * as Socket from "effect/unstable/socket/Socket"
22
+ import { Effect, Option, Predicate, Redacted, Schema, Stream } from "effect"
23
+ import * as Socket from "effect/socket/Socket"
24
24
  import * as AiError from "@effect-uai/core/AiError"
25
25
  import type { AudioChunk, AudioFormat } from "@effect-uai/core/Audio"
26
26
  import * as JSONL from "@effect-uai/core/JSONL"
27
27
  import type { CustomPronunciation } from "@effect-uai/core/SpeechSynthesizer"
28
+ import * as WebSocketSession from "@effect-uai/core/WebSocketSession"
28
29
  import { audioConfigFor, decodeAudioContent, defaultFormat } from "./codec.js"
29
30
  import type { InworldDeliveryMode, InworldTtsModel, InworldVoiceId } from "./models.js"
30
31
  import { authedWsConstructor } from "./wsAuth.js"
31
32
 
32
- export type Config = { readonly apiKey: Redacted.Redacted; readonly baseUrl?: string }
33
+ export type Config = {
34
+ readonly apiKey: Redacted.Redacted
35
+ readonly baseUrl?: string
36
+ /**
37
+ * Build the socket yourself, for a proxy or an in-memory transport. The
38
+ * default attaches the Basic auth header through the `ws` package.
39
+ */
40
+ readonly webSocket?: Socket.WebSocketConstructor["Service"]
41
+ }
33
42
 
34
43
  /**
35
44
  * Incremental-text-in request. Mirrors the sync request minus `text` (which
@@ -110,21 +119,25 @@ const ServerFrame = Schema.Struct({
110
119
  })
111
120
  const decodeServerFrame = Schema.decodeUnknownEffect(ServerFrame)
112
121
 
113
- const handleServerFrame = (queue: Queue.Queue<AudioChunk, Cause.Done>) => (raw: string) =>
122
+ /** One raw text frame to at most one audio chunk; error frames are logged, not raised. */
123
+ const frameToChunk = (raw: string): Effect.Effect<AudioChunk | undefined> =>
114
124
  Effect.gen(function* () {
115
125
  const json = yield* JSONL.parseSafe(raw)
116
- if (json === undefined) return
126
+ if (json === undefined) return undefined
117
127
  const decoded = yield* decodeServerFrame(json).pipe(Effect.option)
118
- if (decoded._tag === "None") return
128
+ if (Option.isNone(decoded)) return undefined
119
129
  const frame = decoded.value
120
130
  if (frame.error !== undefined) {
121
131
  yield* Effect.logWarning("[inworld-tts] server error frame", { error: frame.error })
122
- return
132
+ return undefined
123
133
  }
124
134
  const audio = frame.result?.audioChunk?.audioContent
125
- if (audio === undefined || audio === "") return
135
+ if (audio === undefined || audio === "") return undefined
126
136
  const bytes = yield* decodeAudioContent(audio).pipe(Effect.option)
127
- if (bytes._tag === "Some") yield* Queue.offer(queue, { bytes: bytes.value })
137
+ return Option.match(bytes, {
138
+ onNone: () => undefined,
139
+ onSome: (b): AudioChunk => ({ bytes: b }),
140
+ })
128
141
  })
129
142
 
130
143
  // ---------------------------------------------------------------------------
@@ -153,32 +166,35 @@ export const streamSynthesis =
153
166
  )
154
167
  }
155
168
  const create = yield* createFrame(request)
156
- const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg), {
157
- // Effect's Socket treats all close codes as errors by default —
158
- // whitelist standard clean-close codes (1000 / 1001 / 1005).
159
- closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
160
- }).pipe(Effect.provideService(Socket.WebSocketConstructor, authedWsConstructor(cfg.apiKey)))
161
- const queue = yield* Queue.bounded<AudioChunk, Cause.Done>(64)
162
- const write = yield* socket.writer
163
-
164
- // Writer: BOS `create` → drain text as `send_text` → `close_context`.
165
- // The reader fiber surfaces the remaining audio drained by the server
166
- // post-close and shuts the queue when the WS upstream closes.
167
- yield* Effect.gen(function* () {
168
- yield* write(create)
169
- yield* Stream.runForEach(textIn, (text) =>
170
- text.length === 0 ? Effect.void : write(sendTextFrame(text)),
171
- )
172
- yield* write(closeContextFrame)
173
- }).pipe(Effect.ignore, Effect.forkScoped)
174
-
175
- // `Queue.end` flushes pending chunks then fails the next take with
176
- // `Done` — clean stream end. `Queue.shutdown` would CLEAR queued items
177
- // and interrupt pending takes (wrong for graceful teardown).
178
- yield* socket
179
- .runString(handleServerFrame(queue))
180
- .pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
181
-
182
- return Stream.fromQueue(queue)
169
+ const socket = yield* Socket.makeWebSocket(buildWsUrl(cfg)).pipe(
170
+ Effect.provideService(
171
+ Socket.WebSocketConstructor,
172
+ cfg.webSocket ?? authedWsConstructor(cfg.apiKey),
173
+ ),
174
+ )
175
+ const { write } = yield* socket.writer
176
+ const asAiError = WebSocketSession.toAiError("inworld")
177
+
178
+ const chunks = Stream.fromPull(Socket.readerString(socket)).pipe(
179
+ Stream.scoped,
180
+ Stream.catchIf(WebSocketSession.isCleanClose, () => Stream.empty),
181
+ Stream.mapError(asAiError),
182
+ Stream.mapEffect(frameToChunk),
183
+ Stream.filter(Predicate.isNotUndefined),
184
+ )
185
+
186
+ const outgoing = Stream.concat(
187
+ Stream.make(create),
188
+ Stream.concat(
189
+ textIn.pipe(
190
+ Stream.filter((text) => text.length > 0),
191
+ Stream.map(sendTextFrame),
192
+ ),
193
+ Stream.make(closeContextFrame),
194
+ ),
195
+ ).pipe(Stream.mapEffect((frame) => Effect.mapError(write(frame), asAiError)))
196
+
197
+ // The server drains remaining audio after `close_context`, then closes.
198
+ return Stream.merge(chunks, Stream.drain(outgoing), { haltStrategy: "left" })
183
199
  }),
184
200
  )
package/src/wsAuth.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * we use the `ws` peer dep (Node/Bun only).
10
10
  */
11
11
  import { Redacted } from "effect"
12
- import type * as Socket from "effect/unstable/socket/Socket"
12
+ import type * as Socket from "effect/socket/Socket"
13
13
  import { WebSocket as WSWebSocket } from "ws"
14
14
  import { authHeader } from "./codec.js"
15
15