@effect-uai/core 0.14.0 → 0.16.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/README.md +30 -16
- package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
- package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
- package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
- package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
- package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
- package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
- package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
- package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
- package/dist/chunker/Chunker.d.mts +1 -1
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +9 -2
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Realtime.d.mts +616 -0
- package/dist/domain/Realtime.d.mts.map +1 -0
- package/dist/domain/Realtime.mjs +21 -0
- package/dist/domain/Realtime.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +17 -3
- package/dist/domain/Transcript.d.mts.map +1 -1
- package/dist/domain/Transcript.mjs +30 -1
- package/dist/domain/Transcript.mjs.map +1 -1
- package/dist/domain/Transcript.test.d.mts +1 -0
- package/dist/domain/Transcript.test.mjs +110 -0
- package/dist/domain/Transcript.test.mjs.map +1 -0
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/image-generator/ImageGenerator.d.mts +1 -1
- package/dist/index.d.mts +11 -4
- package/dist/index.mjs +9 -2
- package/dist/job/Job.d.mts +1 -1
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/messenger/Messenger.d.mts +472 -0
- package/dist/messenger/Messenger.d.mts.map +1 -0
- package/dist/messenger/Messenger.mjs +66 -0
- package/dist/messenger/Messenger.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.d.mts +35 -0
- package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
- package/dist/messenger/MessengerAdapter.mjs +102 -0
- package/dist/messenger/MessengerAdapter.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
- package/dist/messenger/MessengerAdapter.test.mjs +138 -0
- package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
- package/dist/messenger/MessengerError.d.mts +2 -0
- package/dist/messenger/MessengerError.mjs +39 -0
- package/dist/messenger/MessengerError.mjs.map +1 -0
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/realtime/RealtimeSession.d.mts +50 -0
- package/dist/realtime/RealtimeSession.d.mts.map +1 -0
- package/dist/realtime/RealtimeSession.mjs +45 -0
- package/dist/realtime/RealtimeSession.mjs.map +1 -0
- package/dist/realtime/RealtimeSession.test.d.mts +1 -0
- package/dist/realtime/RealtimeSession.test.mjs +95 -0
- package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
- package/dist/reranker/Reranker.d.mts +1 -1
- package/dist/research/DeepResearch.d.mts +1 -1
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/Settle.d.mts +45 -0
- package/dist/streaming/Settle.d.mts.map +1 -0
- package/dist/streaming/Settle.mjs +95 -0
- package/dist/streaming/Settle.mjs.map +1 -0
- package/dist/streaming/Settle.test.d.mts +1 -0
- package/dist/streaming/Settle.test.mjs +252 -0
- package/dist/streaming/Settle.test.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.d.mts +36 -0
- package/dist/streaming/WebSocketSession.d.mts.map +1 -0
- package/dist/streaming/WebSocketSession.mjs +89 -0
- package/dist/streaming/WebSocketSession.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.test.d.mts +1 -0
- package/dist/streaming/WebSocketSession.test.mjs +51 -0
- package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
- package/dist/testing/FakeWebSocket.d.mts +25 -0
- package/dist/testing/FakeWebSocket.d.mts.map +1 -0
- package/dist/testing/FakeWebSocket.mjs +79 -0
- package/dist/testing/FakeWebSocket.mjs.map +1 -0
- package/dist/testing/MockMessenger.d.mts +192 -0
- package/dist/testing/MockMessenger.d.mts.map +1 -0
- package/dist/testing/MockMessenger.mjs +76 -0
- package/dist/testing/MockMessenger.mjs.map +1 -0
- package/dist/testing/MockRealtimeSession.d.mts +30 -0
- package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
- package/dist/testing/MockRealtimeSession.mjs +52 -0
- package/dist/testing/MockRealtimeSession.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/dist/web-read/WebRead.d.mts +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +41 -1
- package/src/domain/AiError.ts +11 -0
- package/src/domain/Realtime.ts +101 -0
- package/src/domain/Transcript.test.ts +98 -0
- package/src/domain/Transcript.ts +53 -1
- package/src/index.ts +7 -0
- package/src/messenger/Messenger.ts +293 -0
- package/src/messenger/MessengerAdapter.test.ts +179 -0
- package/src/messenger/MessengerAdapter.ts +189 -0
- package/src/messenger/MessengerError.ts +67 -0
- package/src/realtime/RealtimeSession.test.ts +100 -0
- package/src/realtime/RealtimeSession.ts +77 -0
- package/src/streaming/Settle.test.ts +344 -0
- package/src/streaming/Settle.ts +196 -0
- package/src/streaming/WebSocketSession.test.ts +52 -0
- package/src/streaming/WebSocketSession.ts +132 -0
- package/src/testing/FakeWebSocket.ts +120 -0
- package/src/testing/MockMessenger.ts +147 -0
- package/src/testing/MockRealtimeSession.ts +92 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acting on a pause in a stream of arrivals.
|
|
3
|
+
*
|
|
4
|
+
* A chat user types three lines then stops; a transcriber emits token deltas
|
|
5
|
+
* then goes quiet. Both want a resetting window: every arrival pushes the
|
|
6
|
+
* deadline out. Effect's `groupedWithin` measures a fixed window from the first
|
|
7
|
+
* element of a batch and `debounce` drops all but the last element, so neither
|
|
8
|
+
* expresses this.
|
|
9
|
+
*
|
|
10
|
+
* `settleBurst` withholds elements until a burst ends; `onQuiet` holds nothing
|
|
11
|
+
* back and only adds an event once things go still.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
Array as Arr,
|
|
15
|
+
Cause,
|
|
16
|
+
Clock,
|
|
17
|
+
Deferred,
|
|
18
|
+
Duration,
|
|
19
|
+
Effect,
|
|
20
|
+
Function,
|
|
21
|
+
Option,
|
|
22
|
+
Pull,
|
|
23
|
+
Queue,
|
|
24
|
+
Ref,
|
|
25
|
+
Result,
|
|
26
|
+
Stream,
|
|
27
|
+
} from "effect"
|
|
28
|
+
|
|
29
|
+
/** Buffer between a source stream and the batcher. 64 matches the realtime adapters. */
|
|
30
|
+
const DEFAULT_CAPACITY = 64
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Block for the first item, then keep taking while the next one lands within
|
|
34
|
+
* `settle` of the previous. The window resets on every arrival, so a burst
|
|
35
|
+
* becomes one batch and a lone message followed by silence returns at once.
|
|
36
|
+
*
|
|
37
|
+
* A take that loses to the window is interrupted before it removes anything,
|
|
38
|
+
* so late arrivals stay queued for the next drain. The queue is the caller's:
|
|
39
|
+
* it owns the backpressure strategy.
|
|
40
|
+
*/
|
|
41
|
+
export const drainBurst = <A, E>(
|
|
42
|
+
queue: Queue.Dequeue<A, E>,
|
|
43
|
+
settle: Duration.Input,
|
|
44
|
+
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {
|
|
45
|
+
const settled = (
|
|
46
|
+
batch: Arr.NonEmptyReadonlyArray<A>,
|
|
47
|
+
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>
|
|
48
|
+
Queue.take(queue).pipe(
|
|
49
|
+
Effect.timeoutOption(settle),
|
|
50
|
+
Effect.flatMap(
|
|
51
|
+
Option.match({
|
|
52
|
+
onNone: () => Effect.succeed(batch),
|
|
53
|
+
onSome: (item) => settled(Arr.append(batch, item)),
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
// A queue that ends mid-burst yields the batch in hand. Interrupts and
|
|
57
|
+
// real failures still propagate.
|
|
58
|
+
Effect.catchCause((cause) =>
|
|
59
|
+
Pull.isDoneCause(cause) ? Effect.succeed(batch) : Effect.failCause(cause),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
return Effect.flatMap(Queue.take(queue), (first) => settled([first]))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SettleBurstOptions = {
|
|
66
|
+
/** Elements buffered ahead of the batcher. A full buffer suspends the source. */
|
|
67
|
+
readonly capacity?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `drainBurst` over a stream: one array per burst, nothing while a burst is
|
|
72
|
+
* still growing.
|
|
73
|
+
*
|
|
74
|
+
* Detecting a pause means reading ahead of the consumer, so this buffers by
|
|
75
|
+
* construction. The buffer is bounded, so a slow consumer suspends the source
|
|
76
|
+
* instead of growing memory.
|
|
77
|
+
*/
|
|
78
|
+
export const settleBurst: {
|
|
79
|
+
(
|
|
80
|
+
settle: Duration.Input,
|
|
81
|
+
options?: SettleBurstOptions,
|
|
82
|
+
): <A, E, R>(self: Stream.Stream<A, E, R>) => Stream.Stream<ReadonlyArray<A>, E, R>
|
|
83
|
+
<A, E, R>(
|
|
84
|
+
self: Stream.Stream<A, E, R>,
|
|
85
|
+
settle: Duration.Input,
|
|
86
|
+
options?: SettleBurstOptions,
|
|
87
|
+
): Stream.Stream<ReadonlyArray<A>, E, R>
|
|
88
|
+
} = Function.dual(
|
|
89
|
+
(args) => Stream.isStream(args[0]),
|
|
90
|
+
<A, E, R>(
|
|
91
|
+
self: Stream.Stream<A, E, R>,
|
|
92
|
+
settle: Duration.Input,
|
|
93
|
+
options?: SettleBurstOptions,
|
|
94
|
+
): Stream.Stream<ReadonlyArray<A>, E, R> =>
|
|
95
|
+
Stream.unwrap(
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
// `Cause.Done` as the failure type is what makes `Queue.end` available;
|
|
98
|
+
// `Queue.shutdown` would clear items still waiting to be batched.
|
|
99
|
+
const queue = yield* Queue.bounded<A, E | Cause.Done>(options?.capacity ?? DEFAULT_CAPACITY)
|
|
100
|
+
yield* Stream.runForEach(self, (a) => Queue.offer(queue, a)).pipe(
|
|
101
|
+
// A failing source must reach the consumer. Ending the queue instead
|
|
102
|
+
// would present the failure as a clean end of stream.
|
|
103
|
+
Effect.matchCauseEffect({
|
|
104
|
+
onFailure: (cause) =>
|
|
105
|
+
Cause.hasFails(cause) ? Queue.failCause(queue, cause) : Queue.end(queue),
|
|
106
|
+
onSuccess: () => Queue.end(queue),
|
|
107
|
+
}),
|
|
108
|
+
Effect.forkScoped,
|
|
109
|
+
)
|
|
110
|
+
return Stream.fromPull(
|
|
111
|
+
Effect.succeed(Effect.map(drainBurst(queue, settle), (batch) => [batch] as const)),
|
|
112
|
+
)
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pass every element through the moment it arrives, and emit whatever `emit`
|
|
119
|
+
* yields once nothing has arrived for `settle`. At most one emission per quiet
|
|
120
|
+
* period: the next element re-arms it.
|
|
121
|
+
*
|
|
122
|
+
* Nothing is held back, so this suits live output that wants a boundary marker
|
|
123
|
+
* rather than batching. Ends with the source.
|
|
124
|
+
*/
|
|
125
|
+
export const onQuiet: {
|
|
126
|
+
<B>(
|
|
127
|
+
settle: Duration.Input,
|
|
128
|
+
emit: Effect.Effect<Option.Option<B>>,
|
|
129
|
+
): <A, E, R>(self: Stream.Stream<A, E, R>) => Stream.Stream<A | B, E, R>
|
|
130
|
+
<A, E, R, B>(
|
|
131
|
+
self: Stream.Stream<A, E, R>,
|
|
132
|
+
settle: Duration.Input,
|
|
133
|
+
emit: Effect.Effect<Option.Option<B>>,
|
|
134
|
+
): Stream.Stream<A | B, E, R>
|
|
135
|
+
} = Function.dual(
|
|
136
|
+
3,
|
|
137
|
+
<A, E, R, B>(
|
|
138
|
+
self: Stream.Stream<A, E, R>,
|
|
139
|
+
settle: Duration.Input,
|
|
140
|
+
emit: Effect.Effect<Option.Option<B>>,
|
|
141
|
+
): Stream.Stream<A | B, E, R> =>
|
|
142
|
+
Stream.unwrap(
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
const settleMs = Duration.toMillis(settle)
|
|
145
|
+
const lastAt = yield* Ref.make(0)
|
|
146
|
+
// Opened by the first arrival after each emission. An idle stream never
|
|
147
|
+
// wakes the metronome at all.
|
|
148
|
+
const gate = yield* Ref.make(yield* Deferred.make<void>())
|
|
149
|
+
|
|
150
|
+
const source = self.pipe(
|
|
151
|
+
Stream.tap(() =>
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
// Stamp before opening: the metronome re-reads the stamp after it
|
|
154
|
+
// swaps the gate, so an arrival that races the swap still counts.
|
|
155
|
+
const now = yield* Clock.currentTimeMillis
|
|
156
|
+
yield* Ref.set(lastAt, now)
|
|
157
|
+
const open = yield* Ref.get(gate)
|
|
158
|
+
yield* Deferred.succeed(open, undefined)
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const quietFor = Effect.map(
|
|
164
|
+
Effect.all([Clock.currentTimeMillis, Ref.get(lastAt)]),
|
|
165
|
+
([now, last]) => now - last,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// Waits for the gate, then sleeps out whatever is left of the window.
|
|
169
|
+
// Never polls, whether the source is busy or idle.
|
|
170
|
+
const tick = Effect.gen(function* () {
|
|
171
|
+
yield* Effect.flatMap(Ref.get(gate), Deferred.await)
|
|
172
|
+
const elapsed = yield* quietFor
|
|
173
|
+
if (elapsed < settleMs) {
|
|
174
|
+
yield* Effect.sleep(Duration.millis(settleMs - elapsed))
|
|
175
|
+
return Option.none<B>()
|
|
176
|
+
}
|
|
177
|
+
const next = yield* Deferred.make<void>()
|
|
178
|
+
yield* Ref.set(gate, next)
|
|
179
|
+
// An arrival between the check and the swap opened the old gate, so
|
|
180
|
+
// carry it over rather than sleeping through the burst it started.
|
|
181
|
+
if ((yield* quietFor) < settleMs) {
|
|
182
|
+
yield* Deferred.succeed(next, undefined)
|
|
183
|
+
return Option.none<B>()
|
|
184
|
+
}
|
|
185
|
+
return yield* emit
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const metronome = Stream.fromEffect(tick).pipe(
|
|
189
|
+
Stream.forever,
|
|
190
|
+
Stream.filterMap((o) => (Option.isSome(o) ? Result.succeed(o.value) : Result.failVoid)),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return source.pipe(Stream.merge(metronome, { haltStrategy: "left" }))
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
3
|
+
import { expect } from "vitest"
|
|
4
|
+
import * as AiError from "../domain/AiError.js"
|
|
5
|
+
import { toAiError } from "./WebSocketSession.js"
|
|
6
|
+
|
|
7
|
+
const KEY = "AIzaSyEXAMPLEKEY"
|
|
8
|
+
const URL = `wss://generativelanguage.googleapis.com/ws/BidiGenerateContent?key=${KEY}`
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What Bun hands the `error` listener when the upgrade is rejected: the whole
|
|
12
|
+
* socket URL, in prose, on a prototype getter. Node and Deno say less, but the
|
|
13
|
+
* adapter cannot know which runtime it is on.
|
|
14
|
+
*/
|
|
15
|
+
class BunErrorEvent {
|
|
16
|
+
get message(): string {
|
|
17
|
+
return `WebSocket connection to '${URL}' failed: Connection ended`
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Deno names the status but not the URL, also on a prototype getter. */
|
|
22
|
+
class DenoErrorEvent {
|
|
23
|
+
constructor(private readonly detail: string) {}
|
|
24
|
+
get message(): string {
|
|
25
|
+
return `NetworkError: failed to connect to WebSocket: ${this.detail}`
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const openFailure = (cause: unknown) =>
|
|
30
|
+
new Socket.SocketError({ reason: new Socket.SocketOpenError({ kind: "Unknown", cause }) })
|
|
31
|
+
|
|
32
|
+
describe("WebSocketSession errors", () => {
|
|
33
|
+
it("keeps the key out of the error a rejected upgrade produces", () => {
|
|
34
|
+
const error = toAiError("google")(openFailure(new BunErrorEvent()))
|
|
35
|
+
|
|
36
|
+
expect(JSON.stringify(error)).not.toContain(KEY)
|
|
37
|
+
expect(AiError.describe(error)).not.toContain(KEY)
|
|
38
|
+
// The host is still there: an error that says nothing is its own problem.
|
|
39
|
+
expect(JSON.stringify(error)).toContain("generativelanguage.googleapis.com")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("reads the rejected status out of the prose, wherever the runtime put it", () => {
|
|
43
|
+
// Deno's wording. The status is the only hint a rejected upgrade gives.
|
|
44
|
+
const forbidden = toAiError("google")(
|
|
45
|
+
openFailure(new DenoErrorEvent("Invalid status code: 403")),
|
|
46
|
+
)
|
|
47
|
+
const ended = toAiError("google")(openFailure(new BunErrorEvent()))
|
|
48
|
+
|
|
49
|
+
expect(forbidden._tag).toBe("AuthFailed")
|
|
50
|
+
expect(ended._tag).toBe("Unavailable")
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One JSON-over-WebSocket session: connect, read decoded frames off a queue,
|
|
3
|
+
* write frames back.
|
|
4
|
+
*
|
|
5
|
+
* For duplex protocols where the caller drives the conversation frame by
|
|
6
|
+
* frame. The stream-shaped adapters (realtime STT and TTS) still carry their
|
|
7
|
+
* own copies of this.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
Cause,
|
|
11
|
+
type Duration,
|
|
12
|
+
Effect,
|
|
13
|
+
Match,
|
|
14
|
+
Option,
|
|
15
|
+
Predicate,
|
|
16
|
+
Queue,
|
|
17
|
+
Schema,
|
|
18
|
+
type Scope,
|
|
19
|
+
} from "effect"
|
|
20
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
21
|
+
import * as AiError from "../domain/AiError.js"
|
|
22
|
+
import * as JSONL from "./JSONL.js"
|
|
23
|
+
|
|
24
|
+
export type WebSocketSession<A> = {
|
|
25
|
+
/** Fails `Unavailable` once the socket is closed. */
|
|
26
|
+
readonly send: (frame: string) => Effect.Effect<void, AiError.AiError>
|
|
27
|
+
/** Ends when the socket closes cleanly, fails when it does not. */
|
|
28
|
+
readonly frames: Queue.Dequeue<A, AiError.AiError | Cause.Done>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OpenOptions<A, I> = {
|
|
32
|
+
readonly url: string
|
|
33
|
+
readonly provider: string
|
|
34
|
+
/** Frames that do not decode are dropped, and logged at debug level. */
|
|
35
|
+
readonly schema: Schema.Codec<A, I>
|
|
36
|
+
readonly openTimeout?: Duration.Input
|
|
37
|
+
readonly capacity?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The cause of a failed open is a DOM `ErrorEvent`, not an `Error`, and its
|
|
42
|
+
* `message` is a prototype getter, so a structural decode does not see it.
|
|
43
|
+
*/
|
|
44
|
+
const describeCause = (cause: unknown): string =>
|
|
45
|
+
Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)
|
|
46
|
+
? cause.message
|
|
47
|
+
: String(cause)
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Some wires authenticate with a key in the query string, and some runtimes
|
|
51
|
+
* name the socket URL in their open error (Bun does). Everything taken off a
|
|
52
|
+
* socket error goes through this.
|
|
53
|
+
*/
|
|
54
|
+
export const redactUrl = (text: string): string =>
|
|
55
|
+
text.replace(/(\b(?:wss?|https?):\/\/[^\s"']*?)\?[^\s"']*/gi, "$1?<redacted>")
|
|
56
|
+
|
|
57
|
+
const detailOf = Match.type<Socket.SocketError["reason"]>().pipe(
|
|
58
|
+
Match.tag("SocketCloseError", (reason) => reason.message),
|
|
59
|
+
Match.orElse((reason) => describeCause(reason.cause)),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
/** Never the `SocketError` itself: callers print `raw` with `Cause.pretty`. */
|
|
63
|
+
const rawOf = (error: Socket.SocketError): string =>
|
|
64
|
+
redactUrl(`${error.reason._tag}: ${detailOf(error.reason)}`)
|
|
65
|
+
|
|
66
|
+
/** A rejected WS upgrade surfaces only as prose, so the status is read from it. */
|
|
67
|
+
const isAuthRejection = (text: string): boolean => /\b40[13]\b/.test(text)
|
|
68
|
+
|
|
69
|
+
export const toAiError =
|
|
70
|
+
(provider: string) =>
|
|
71
|
+
(error: Socket.SocketError): AiError.AiError => {
|
|
72
|
+
const raw = rawOf(error)
|
|
73
|
+
return Match.value(error.reason).pipe(
|
|
74
|
+
Match.tag("SocketOpenError", (reason) =>
|
|
75
|
+
Match.value(reason.kind).pipe(
|
|
76
|
+
Match.when("Timeout", (): AiError.AiError => new AiError.Timeout({ provider, raw })),
|
|
77
|
+
Match.orElse((): AiError.AiError =>
|
|
78
|
+
isAuthRejection(describeCause(reason.cause))
|
|
79
|
+
? new AiError.AuthFailed({ provider, subtype: "auth", raw })
|
|
80
|
+
: new AiError.Unavailable({ provider, raw }),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
Match.orElse((): AiError.AiError => new AiError.Unavailable({ provider, raw })),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Connect for the lifetime of the surrounding `Scope`. The reader runs in a
|
|
90
|
+
* forked fiber; a dirty close reaches the consumer as a failed queue rather
|
|
91
|
+
* than a clean end.
|
|
92
|
+
*/
|
|
93
|
+
export const open = <A, I>(
|
|
94
|
+
options: OpenOptions<A, I>,
|
|
95
|
+
): Effect.Effect<WebSocketSession<A>, AiError.AiError, Scope.Scope | Socket.WebSocketConstructor> =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const asAiError = toAiError(options.provider)
|
|
98
|
+
const decode = Schema.decodeUnknownEffect(options.schema)
|
|
99
|
+
const socket = yield* Socket.makeWebSocket(options.url, {
|
|
100
|
+
// Effect's Socket treats every close code as an error by default.
|
|
101
|
+
closeCodeIsError: (code) => code !== 1000 && code !== 1001 && code !== 1005,
|
|
102
|
+
...(options.openTimeout !== undefined && { openTimeout: options.openTimeout }),
|
|
103
|
+
})
|
|
104
|
+
const frames = yield* Queue.bounded<A, AiError.AiError | Cause.Done>(options.capacity ?? 64)
|
|
105
|
+
const write = yield* socket.writer
|
|
106
|
+
|
|
107
|
+
const onFrame = (raw: string) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const json = yield* JSONL.parseSafe(raw)
|
|
110
|
+
if (json === undefined) return
|
|
111
|
+
const decoded = yield* decode(json).pipe(Effect.option)
|
|
112
|
+
if (Option.isNone(decoded)) {
|
|
113
|
+
yield* Effect.logDebug(`[${options.provider}] dropped frame`, { frame: json })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
yield* Queue.offer(frames, decoded.value)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
yield* socket.runString(onFrame).pipe(
|
|
120
|
+
Effect.mapError(asAiError),
|
|
121
|
+
// `Queue.end` on a clean close so the consumer drains what is queued;
|
|
122
|
+
// a failure must not arrive looking like the end of the conversation.
|
|
123
|
+
Effect.matchCauseEffect({
|
|
124
|
+
onFailure: (cause) =>
|
|
125
|
+
Cause.hasFails(cause) ? Queue.failCause(frames, cause) : Queue.end(frames),
|
|
126
|
+
onSuccess: () => Queue.end(frames),
|
|
127
|
+
}),
|
|
128
|
+
Effect.forkScoped,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return { send: (frame: string) => write(frame).pipe(Effect.mapError(asAiError)), frames }
|
|
132
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory WebSocket for testing socket adapters without a server.
|
|
3
|
+
*
|
|
4
|
+
* Provide `layer` in place of the real `Socket.WebSocketConstructor` and the
|
|
5
|
+
* adapter runs end to end: its own connect, handshake, decode and teardown.
|
|
6
|
+
* The test plays the other end with `reply`, `push`, `close` and `sent`.
|
|
7
|
+
*
|
|
8
|
+
* Client frames land on a queue, so the scripted server is a forked fiber and
|
|
9
|
+
* `reply` is a real Effect. What stays imperative is the listener table the
|
|
10
|
+
* shim registers, since `Socket.makeWebSocket` drives it with plain callbacks.
|
|
11
|
+
* The shim is a plain object, so nothing here touches a platform global.
|
|
12
|
+
*/
|
|
13
|
+
import { Effect, Layer, Queue, Ref, type Scope, Stream } from "effect"
|
|
14
|
+
import * as Socket from "effect/unstable/socket/Socket"
|
|
15
|
+
|
|
16
|
+
export type FakeWebSocketServer = {
|
|
17
|
+
/** Every frame the client has sent, JSON-parsed, oldest first. */
|
|
18
|
+
readonly sent: Effect.Effect<ReadonlyArray<unknown>>
|
|
19
|
+
/** Deliver one frame to the client. */
|
|
20
|
+
readonly push: (frame: unknown) => Effect.Effect<void>
|
|
21
|
+
/** Close the socket. 1000, 1001 and 1005 are the clean codes. */
|
|
22
|
+
readonly close: (code?: number) => Effect.Effect<void>
|
|
23
|
+
/** Hand this to an adapter that takes a socket constructor directly. */
|
|
24
|
+
readonly connect: Socket.WebSocketConstructor["Service"]
|
|
25
|
+
/** Provide this in place of the real constructor. */
|
|
26
|
+
readonly layer: Layer.Layer<Socket.WebSocketConstructor>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FakeWebSocketOptions = {
|
|
30
|
+
/** Pushed once the client subscribes, for a protocol whose server speaks first. */
|
|
31
|
+
readonly greeting?: ReadonlyArray<unknown>
|
|
32
|
+
/** How the server answers each client frame. */
|
|
33
|
+
readonly reply?: (frame: unknown) => Effect.Effect<ReadonlyArray<unknown>>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Listener = (event: any) => void
|
|
37
|
+
|
|
38
|
+
const OPEN = 1
|
|
39
|
+
const CLOSED = 3
|
|
40
|
+
|
|
41
|
+
/** Run after the current call unwinds, so the client is never re-entered. */
|
|
42
|
+
const soon = (run: () => void): void => void Promise.resolve().then(run)
|
|
43
|
+
|
|
44
|
+
export const make = (
|
|
45
|
+
options?: FakeWebSocketOptions,
|
|
46
|
+
): Effect.Effect<FakeWebSocketServer, never, Scope.Scope> =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const inbound = yield* Queue.unbounded<unknown>()
|
|
49
|
+
const sent = yield* Ref.make<ReadonlyArray<unknown>>([])
|
|
50
|
+
|
|
51
|
+
const wiring = {
|
|
52
|
+
listeners: {} as Record<string, ReadonlyArray<Listener>>,
|
|
53
|
+
readyState: OPEN,
|
|
54
|
+
greeted: false,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const emit = (type: string, event: unknown): void =>
|
|
58
|
+
(wiring.listeners[type] ?? []).forEach((listener) => listener(event))
|
|
59
|
+
|
|
60
|
+
const deliverSync = (frames: ReadonlyArray<unknown>): void =>
|
|
61
|
+
frames.forEach((f) => emit("message", { data: JSON.stringify(f) }))
|
|
62
|
+
|
|
63
|
+
const deliver = (frames: ReadonlyArray<unknown>): Effect.Effect<void> =>
|
|
64
|
+
Effect.sync(() => deliverSync(frames))
|
|
65
|
+
|
|
66
|
+
const shutdown = (code: number): void => {
|
|
67
|
+
if (wiring.readyState === CLOSED) return
|
|
68
|
+
wiring.readyState = CLOSED
|
|
69
|
+
emit("close", { code, reason: "" })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The scripted server. Answers land on their own turn, like a real one.
|
|
73
|
+
yield* Stream.fromQueue(inbound).pipe(
|
|
74
|
+
Stream.runForEach((frame) =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
yield* Ref.update(sent, (frames) => [...frames, frame])
|
|
77
|
+
const answer = yield* options?.reply?.(frame) ?? Effect.succeed([])
|
|
78
|
+
yield* deliver(answer)
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
Effect.forkScoped,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const socket = {
|
|
85
|
+
get readyState() {
|
|
86
|
+
return wiring.readyState
|
|
87
|
+
},
|
|
88
|
+
addEventListener: (type: string, fn: Listener): void => {
|
|
89
|
+
wiring.listeners = {
|
|
90
|
+
...wiring.listeners,
|
|
91
|
+
[type]: [...(wiring.listeners[type] ?? []), fn],
|
|
92
|
+
}
|
|
93
|
+
// The greeting waits for a subscriber before it lands.
|
|
94
|
+
if (type === "message" && !wiring.greeted) {
|
|
95
|
+
wiring.greeted = true
|
|
96
|
+
soon(() => deliverSync(options?.greeting ?? []))
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
removeEventListener: (type: string, fn: Listener): void => {
|
|
100
|
+
wiring.listeners = {
|
|
101
|
+
...wiring.listeners,
|
|
102
|
+
[type]: (wiring.listeners[type] ?? []).filter((l) => l !== fn),
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
// Safe from a synchronous callback, and no runtime needed.
|
|
106
|
+
send: (data: string): void => void Queue.offerUnsafe(inbound, JSON.parse(data)),
|
|
107
|
+
close: (code = 1000): void => shutdown(code),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const connect: Socket.WebSocketConstructor["Service"] = () =>
|
|
111
|
+
socket as unknown as globalThis.WebSocket
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
sent: Ref.get(sent),
|
|
115
|
+
push: (frame) => deliver([frame]),
|
|
116
|
+
close: (code = 1000) => Effect.sync(() => shutdown(code)),
|
|
117
|
+
connect,
|
|
118
|
+
layer: Layer.succeed(Socket.WebSocketConstructor, connect),
|
|
119
|
+
}
|
|
120
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Array as Arr, Data, Effect, Layer, Option, Ref, Stream } from "effect"
|
|
2
|
+
import {
|
|
3
|
+
type ConversationRef,
|
|
4
|
+
CurrentConversation,
|
|
5
|
+
type InboundEvent,
|
|
6
|
+
MessageId,
|
|
7
|
+
type MessageRef,
|
|
8
|
+
Messenger,
|
|
9
|
+
type MessengerLimits,
|
|
10
|
+
type MessengerService,
|
|
11
|
+
type Outbound,
|
|
12
|
+
} from "../messenger/Messenger.js"
|
|
13
|
+
import {
|
|
14
|
+
type StreamViaEditsOptions,
|
|
15
|
+
splitForLimit,
|
|
16
|
+
streamViaEdits,
|
|
17
|
+
} from "../messenger/MessengerAdapter.js"
|
|
18
|
+
import * as MessengerError from "../messenger/MessengerError.js"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Call log
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One entry per outbound call. `Post` is logged once even when the text was
|
|
26
|
+
* long enough to split, so a caller that rolled over on its own is
|
|
27
|
+
* distinguishable from one that leaned on `post` to do it.
|
|
28
|
+
*/
|
|
29
|
+
export type Call = Data.TaggedEnum<{
|
|
30
|
+
Post: { readonly conversation: ConversationRef; readonly message: Outbound }
|
|
31
|
+
Edit: { readonly message: MessageRef; readonly next: Outbound }
|
|
32
|
+
React: { readonly message: MessageRef; readonly emoji: string }
|
|
33
|
+
TypingStart: { readonly conversation: ConversationRef }
|
|
34
|
+
TypingStop: { readonly conversation: ConversationRef }
|
|
35
|
+
}>
|
|
36
|
+
|
|
37
|
+
export const Call = Data.taggedEnum<Call>()
|
|
38
|
+
|
|
39
|
+
export type MockMessengerRecorder = {
|
|
40
|
+
readonly calls: ReadonlyArray<Call>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Script
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export type MockMessengerScript = {
|
|
48
|
+
/** Inbound events to replay. Empty by default. */
|
|
49
|
+
readonly events?: Stream.Stream<InboundEvent, MessengerError.MessengerError>
|
|
50
|
+
readonly limits?: MessengerLimits
|
|
51
|
+
/**
|
|
52
|
+
* Failures handed back one per `post` / `edit` call, in order; `undefined`
|
|
53
|
+
* entries succeed, and everything past the end succeeds.
|
|
54
|
+
*/
|
|
55
|
+
readonly failures?: ReadonlyArray<MessengerError.MessengerError | undefined>
|
|
56
|
+
/** Passed through to `streamViaEdits`, which backs `stream`. */
|
|
57
|
+
readonly streamOptions?: StreamViaEditsOptions
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultLimits: MessengerLimits = { maxText: 4096, maxCaption: 1024 }
|
|
61
|
+
|
|
62
|
+
const bodyText = (msg: Outbound): Option.Option<string> =>
|
|
63
|
+
msg.body._tag === "Text" ? Option.some(msg.body.text) : Option.none()
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Service builder
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const buildService = (
|
|
70
|
+
script: MockMessengerScript,
|
|
71
|
+
record: (call: Call) => Effect.Effect<void>,
|
|
72
|
+
cursor: Ref.Ref<number>,
|
|
73
|
+
minted: Ref.Ref<number>,
|
|
74
|
+
): MessengerService => {
|
|
75
|
+
const limits = script.limits ?? defaultLimits
|
|
76
|
+
|
|
77
|
+
const nextFailure = Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
|
|
78
|
+
Effect.flatMap((i) =>
|
|
79
|
+
Option.match(Option.fromNullishOr((script.failures ?? [])[i]), {
|
|
80
|
+
onNone: () => Effect.void,
|
|
81
|
+
onSome: Effect.fail<MessengerError.MessengerError>,
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const mint = Ref.updateAndGet(minted, (n) => n + 1).pipe(Effect.map((n) => MessageId(`m${n}`)))
|
|
87
|
+
|
|
88
|
+
const post: MessengerService["post"] = (msg) =>
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const conversation = yield* CurrentConversation
|
|
91
|
+
yield* record(Call.Post({ conversation, message: msg }))
|
|
92
|
+
yield* nextFailure
|
|
93
|
+
// One id per chunk, mirroring an adapter that splits at the ceiling.
|
|
94
|
+
const chunks = Option.match(bodyText(msg), {
|
|
95
|
+
onNone: () => 1,
|
|
96
|
+
onSome: (t) => splitForLimit(t, limits.maxText).length,
|
|
97
|
+
})
|
|
98
|
+
return yield* Effect.map(
|
|
99
|
+
Effect.forEach(Arr.range(1, chunks), () => mint),
|
|
100
|
+
Arr.lastNonEmpty,
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const edit: MessengerService["edit"] = (msg, next) =>
|
|
105
|
+
record(Call.Edit({ message: msg, next })).pipe(Effect.andThen(nextFailure))
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
events: script.events ?? Stream.empty,
|
|
109
|
+
post,
|
|
110
|
+
edit,
|
|
111
|
+
react: (msg, emoji) => record(Call.React({ message: msg, emoji })),
|
|
112
|
+
typing: Effect.gen(function* () {
|
|
113
|
+
const conversation = yield* CurrentConversation
|
|
114
|
+
yield* record(Call.TypingStart({ conversation }))
|
|
115
|
+
yield* Effect.addFinalizer(() => record(Call.TypingStop({ conversation })))
|
|
116
|
+
}),
|
|
117
|
+
stream: streamViaEdits({ post, edit, limits }, script.streamOptions),
|
|
118
|
+
limits,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Public API
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const makeUnsafe = (script: MockMessengerScript) => {
|
|
127
|
+
const calls = Ref.makeUnsafe<ReadonlyArray<Call>>([])
|
|
128
|
+
const service = buildService(
|
|
129
|
+
script,
|
|
130
|
+
(call) => Ref.update(calls, Arr.append(call)),
|
|
131
|
+
Ref.makeUnsafe(0),
|
|
132
|
+
Ref.makeUnsafe(0),
|
|
133
|
+
)
|
|
134
|
+
return {
|
|
135
|
+
service,
|
|
136
|
+
recorder: Ref.get(calls).pipe(Effect.map((c): MockMessengerRecorder => ({ calls: c }))),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Layer registering the mock against the `Messenger` tag, plus its recorder. */
|
|
141
|
+
export const layer = (script: MockMessengerScript = {}) => {
|
|
142
|
+
const { service, recorder } = makeUnsafe(script)
|
|
143
|
+
return { layer: Layer.succeed(Messenger, service), recorder }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Bare service value + recorder. Use with `Effect.provideService`. */
|
|
147
|
+
export const make = (script: MockMessengerScript = {}) => makeUnsafe(script)
|