@effect-uai/ui 0.19.0 → 0.20.0-dev-20261110202152
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/dist/Atoms.d.mts +13 -4
- package/dist/Atoms.d.mts.map +1 -1
- package/dist/Atoms.mjs +8 -2
- package/dist/Atoms.mjs.map +1 -1
- package/dist/{Client-C9UYN_VK.d.mts → Client-B2Eqo39q.d.mts} +5 -4
- package/dist/{Client-C9UYN_VK.d.mts.map → Client-B2Eqo39q.d.mts.map} +1 -1
- package/dist/Client.d.mts +1 -1
- package/dist/Client.mjs +8 -5
- package/dist/Client.mjs.map +1 -1
- package/dist/Run.d.mts +2 -2
- package/dist/Run.d.mts.map +1 -1
- package/dist/SseEncoding.d.mts +10 -3
- package/dist/SseEncoding.d.mts.map +1 -1
- package/dist/SseEncoding.mjs +10 -1
- package/dist/SseEncoding.mjs.map +1 -1
- package/dist/Transport.d.mts +58 -17
- package/dist/Transport.d.mts.map +1 -1
- package/dist/Transport.mjs +81 -21
- package/dist/Transport.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/package.json +4 -4
- package/src/Atoms.test.ts +30 -0
- package/src/Atoms.ts +16 -7
- package/src/Client.ts +9 -7
- package/src/SseEncoding.test.ts +20 -1
- package/src/SseEncoding.ts +18 -1
- package/src/Transport.test.ts +199 -0
- package/src/Transport.ts +173 -34
package/src/Transport.ts
CHANGED
|
@@ -8,8 +8,19 @@
|
|
|
8
8
|
* state passed to `start`, so a request can carry any app data from it (a
|
|
9
9
|
* chat id, settings).
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
DateTime,
|
|
13
|
+
Duration,
|
|
14
|
+
Effect,
|
|
15
|
+
Option,
|
|
16
|
+
Function,
|
|
17
|
+
Predicate,
|
|
18
|
+
Schedule,
|
|
19
|
+
Schema,
|
|
20
|
+
Stream,
|
|
21
|
+
} from "effect"
|
|
22
|
+
import { Sse } from "effect/encoding"
|
|
23
|
+
import { HttpClient, HttpClientError, type HttpClientRequest } from "effect/http"
|
|
13
24
|
import * as Client from "./Client.js"
|
|
14
25
|
import * as Run from "./Run.js"
|
|
15
26
|
import { type Envelope, type RunEvent, StreamId } from "./RunEvent.js"
|
|
@@ -63,36 +74,43 @@ export const local = <I, S, E, R>(
|
|
|
63
74
|
run(input, state).pipe(Run.sequence(options?.streamId ?? StreamId.make("local"))),
|
|
64
75
|
})
|
|
65
76
|
|
|
66
|
-
/** A per-run transport that can re-attach to a run (durable). */
|
|
77
|
+
/** A per-run transport that can re-attach to a run (durable). `start` is `submit`, then `resume` from 0. */
|
|
67
78
|
export type Durable<I, S, E, R> = Transport<I, S, E, R> & {
|
|
79
|
+
/** Start a run on the server and return its stream id. */
|
|
80
|
+
readonly submit: (input: I, state: S) => Effect.Effect<StreamId, E, R>
|
|
68
81
|
readonly resume: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>
|
|
69
82
|
}
|
|
70
83
|
|
|
84
|
+
const durableOf = <I, S, E, R>(parts: Omit<Durable<I, S, E, R>, "start">): Durable<I, S, E, R> => ({
|
|
85
|
+
...parts,
|
|
86
|
+
start: (input, state) =>
|
|
87
|
+
Stream.unwrap(Effect.map(parts.submit(input, state), (streamId) => parts.resume(streamId, 0))),
|
|
88
|
+
})
|
|
89
|
+
|
|
71
90
|
/**
|
|
72
91
|
* Durable runs: `submit` starts one on the server and returns its stream id;
|
|
73
|
-
* `events` reads it from `after
|
|
74
|
-
*
|
|
92
|
+
* `events` reads it from `after`. The run lives on the server, so a reload
|
|
93
|
+
* re-attaches with `resume`. A stream that ends before the run's terminal
|
|
94
|
+
* fails with `Client.Disconnected`; pipe through `reconnect` to resume it.
|
|
75
95
|
*/
|
|
76
96
|
export const durable = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {
|
|
77
97
|
readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>
|
|
78
98
|
readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>
|
|
79
99
|
readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>
|
|
80
|
-
}): Durable<I, S, ES | EE | EX, RS | RE | RX> =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})
|
|
100
|
+
}): Durable<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX> =>
|
|
101
|
+
durableOf<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX>({
|
|
102
|
+
submit: options.submit,
|
|
103
|
+
resume: (streamId, after) =>
|
|
104
|
+
Client.resumable((from) => options.events(streamId, from), { from: after }),
|
|
105
|
+
...(options.stop !== undefined ? { stop: options.stop } : {}),
|
|
106
|
+
})
|
|
88
107
|
|
|
89
108
|
/**
|
|
90
109
|
* A durable session (a chat): one stream carries every run, and the server
|
|
91
110
|
* owns its history. `send` without a stream id starts a new session and
|
|
92
111
|
* returns its id (the app's own chat id, branded); with one, it adds a run to
|
|
93
|
-
* that session. `events` follows the whole session from `after
|
|
94
|
-
*
|
|
95
|
-
* not as a response to `send`.
|
|
112
|
+
* that session. `events` follows the whole session from `after`. The run
|
|
113
|
+
* arrives on that stream, not as a response to `send`.
|
|
96
114
|
*/
|
|
97
115
|
export type Session<I, S, E, R> = {
|
|
98
116
|
readonly send: (
|
|
@@ -104,6 +122,10 @@ export type Session<I, S, E, R> = {
|
|
|
104
122
|
readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>
|
|
105
123
|
}
|
|
106
124
|
|
|
125
|
+
/**
|
|
126
|
+
* A session stream never ends on its own, so one that completes fails with
|
|
127
|
+
* `Client.Disconnected`; pipe through `reconnect` to resume it.
|
|
128
|
+
*/
|
|
107
129
|
export const session = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {
|
|
108
130
|
readonly send: (
|
|
109
131
|
streamId: Option.Option<StreamId>,
|
|
@@ -112,10 +134,14 @@ export const session = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {
|
|
|
112
134
|
) => Effect.Effect<StreamId, ES, RS>
|
|
113
135
|
readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>
|
|
114
136
|
readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>
|
|
115
|
-
}): Session<I, S, ES | EE | EX, RS | RE | RX> =>
|
|
137
|
+
}): Session<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX> => ({
|
|
138
|
+
...options,
|
|
139
|
+
events: (streamId, after) =>
|
|
140
|
+
Client.resumable((from) => options.events(streamId, from), { from: after, until: "never" }),
|
|
141
|
+
})
|
|
116
142
|
|
|
117
143
|
// ---------------------------------------------------------------------------
|
|
118
|
-
// Over HTTP: `durable` and `session` with SSE events
|
|
144
|
+
// Over HTTP: `durable` and `session` with SSE events
|
|
119
145
|
// ---------------------------------------------------------------------------
|
|
120
146
|
|
|
121
147
|
/** What the HTTP helpers can fail with. */
|
|
@@ -126,8 +152,6 @@ type HttpOptions = {
|
|
|
126
152
|
readonly events: (streamId: StreamId, after: number) => HttpClientRequest.HttpClientRequest
|
|
127
153
|
/** The request that stops a stream's active run. */
|
|
128
154
|
readonly stop?: (streamId: StreamId) => HttpClientRequest.HttpClientRequest
|
|
129
|
-
/** When to reconnect a dropped event stream (it resumes from the last `seq`). */
|
|
130
|
-
readonly reconnect: Schedule.Schedule<unknown, HttpError>
|
|
131
155
|
}
|
|
132
156
|
|
|
133
157
|
const stopOver = (stop: HttpOptions["stop"]) =>
|
|
@@ -138,7 +162,7 @@ const stopOver = (stop: HttpOptions["stop"]) =>
|
|
|
138
162
|
/**
|
|
139
163
|
* `durable` over HTTP. `submit` starts a run and returns its stream id (the
|
|
140
164
|
* app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);
|
|
141
|
-
* `events` is read as SSE
|
|
165
|
+
* `events` is read as SSE. Pipe through `reconnect` to survive drops.
|
|
142
166
|
*/
|
|
143
167
|
export const durableHttp = <I, S, ES = never, RS = never>(
|
|
144
168
|
options: HttpOptions & {
|
|
@@ -147,19 +171,14 @@ export const durableHttp = <I, S, ES = never, RS = never>(
|
|
|
147
171
|
): Durable<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>
|
|
148
172
|
durable({
|
|
149
173
|
submit: options.submit,
|
|
150
|
-
events: (streamId, after) =>
|
|
151
|
-
Client.resumable((from) => Client.sse(options.events(streamId, from)), {
|
|
152
|
-
from: after,
|
|
153
|
-
schedule: options.reconnect,
|
|
154
|
-
}),
|
|
174
|
+
events: (streamId, after) => Client.sse(options.events(streamId, after)),
|
|
155
175
|
...stopOver(options.stop),
|
|
156
176
|
})
|
|
157
177
|
|
|
158
178
|
/**
|
|
159
179
|
* `session` over HTTP. `send` adds an input and returns the session's stream
|
|
160
180
|
* id (without one, the server starts a session); `events` follows the whole
|
|
161
|
-
* session as SSE.
|
|
162
|
-
* `reconnect` usually retries forever (e.g. `Schedule.spaced("1 second")`).
|
|
181
|
+
* session as SSE. Pipe through `reconnect` to survive drops.
|
|
163
182
|
*/
|
|
164
183
|
export const sessionHttp = <I, S, ES = never, RS = never>(
|
|
165
184
|
options: HttpOptions & {
|
|
@@ -172,11 +191,131 @@ export const sessionHttp = <I, S, ES = never, RS = never>(
|
|
|
172
191
|
): Session<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>
|
|
173
192
|
session({
|
|
174
193
|
send: options.send,
|
|
175
|
-
events: (streamId, after) =>
|
|
176
|
-
Client.resumable((from) => Client.sse(options.events(streamId, from)), {
|
|
177
|
-
from: after,
|
|
178
|
-
until: "never",
|
|
179
|
-
schedule: options.reconnect,
|
|
180
|
-
}),
|
|
194
|
+
events: (streamId, after) => Client.sse(options.events(streamId, after)),
|
|
181
195
|
...stopOver(options.stop),
|
|
182
196
|
})
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Reconnect: resume a dropped event stream from the last seq
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export type ReconnectOptions<E> = {
|
|
203
|
+
/**
|
|
204
|
+
* When to retry. Default: exponential from 500 ms, capped at 10 s, jittered.
|
|
205
|
+
* Cap your own exponential: the schedule resets only when a frame arrives,
|
|
206
|
+
* so an idle session's backoff otherwise grows without bound.
|
|
207
|
+
*/
|
|
208
|
+
readonly schedule?: Schedule.Schedule<unknown, E | Client.Disconnected>
|
|
209
|
+
/** More errors to treat as transient, on top of drops, timeouts, 408 / 429 / 5xx and SSE `retry:`. */
|
|
210
|
+
readonly while?: Predicate.Predicate<E>
|
|
211
|
+
/** Max attempts in a row; the count resets once a frame arrives. Default: unlimited. */
|
|
212
|
+
readonly times?: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const defaultSchedule = Schedule.min([
|
|
216
|
+
Schedule.exponential("500 millis"),
|
|
217
|
+
Schedule.spaced("10 seconds"),
|
|
218
|
+
]).pipe(Schedule.jittered)
|
|
219
|
+
|
|
220
|
+
const isDisconnected = (error: unknown): error is Client.Disconnected =>
|
|
221
|
+
error instanceof Client.Disconnected
|
|
222
|
+
|
|
223
|
+
const isTransient = (error: unknown) => Sse.Retry.is(error) || HttpClient.isTransientError(error)
|
|
224
|
+
|
|
225
|
+
const parseSeconds = Schema.decodeUnknownOption(Schema.FiniteFromString)
|
|
226
|
+
|
|
227
|
+
// `Retry-After` is either seconds or an HTTP date.
|
|
228
|
+
const retryAfter = (value: string, now: number): Option.Option<Duration.Duration> =>
|
|
229
|
+
parseSeconds(value).pipe(
|
|
230
|
+
Option.map((seconds) => Duration.seconds(Math.max(0, seconds))),
|
|
231
|
+
Option.orElse(() =>
|
|
232
|
+
DateTime.make(value).pipe(
|
|
233
|
+
Option.map((at) => Duration.millis(Math.max(0, DateTime.toEpochMillis(at) - now))),
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// The wait the server asked for: an SSE `retry:` line or a `Retry-After` header.
|
|
239
|
+
const serverDelay = (error: unknown, now: number): Option.Option<Duration.Duration> => {
|
|
240
|
+
if (Sse.Retry.is(error)) return Option.some(error.duration)
|
|
241
|
+
if (!HttpClientError.isHttpClientError(error)) return Option.none()
|
|
242
|
+
return Option.fromNullishOr(error.response?.headers["retry-after"]).pipe(
|
|
243
|
+
Option.flatMap((value) => retryAfter(value, now)),
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const policy = <E>(
|
|
248
|
+
options: ReconnectOptions<E> | undefined,
|
|
249
|
+
): Schedule.Schedule<unknown, E | Client.Disconnected> => {
|
|
250
|
+
const base = options?.schedule ?? defaultSchedule
|
|
251
|
+
const extra = options?.while
|
|
252
|
+
return (options?.times === undefined ? base : Schedule.upTo(base, { times: options.times })).pipe(
|
|
253
|
+
Schedule.while(
|
|
254
|
+
({ input }) =>
|
|
255
|
+
isDisconnected(input) || isTransient(input) || (extra !== undefined && extra(input)),
|
|
256
|
+
),
|
|
257
|
+
Schedule.modifyDelay(({ input, duration, now }) =>
|
|
258
|
+
Effect.succeed(
|
|
259
|
+
Option.match(serverDelay(input, now), {
|
|
260
|
+
onNone: () => duration,
|
|
261
|
+
onSome: (asked) => Duration.max(duration, asked),
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A transport `reconnect` applies to: one with a position to resume from. */
|
|
269
|
+
export type Resumable = Session<any, any, any, any> | Durable<any, any, any, any>
|
|
270
|
+
|
|
271
|
+
/** What a transport can fail with. */
|
|
272
|
+
export type ErrorOf<T extends Resumable> =
|
|
273
|
+
T extends Session<any, any, infer E, any>
|
|
274
|
+
? E
|
|
275
|
+
: T extends Durable<any, any, infer E, any>
|
|
276
|
+
? E
|
|
277
|
+
: never
|
|
278
|
+
|
|
279
|
+
type WithDisconnected<F> = F extends (...args: infer A) => Stream.Stream<infer X, infer E, infer R>
|
|
280
|
+
? (...args: A) => Stream.Stream<X, E | Client.Disconnected, R>
|
|
281
|
+
: F
|
|
282
|
+
|
|
283
|
+
/** `T` with `Client.Disconnected` added to its streams; `send` / `submit` keep their exact types. */
|
|
284
|
+
export type Reconnected<T extends Resumable> = { readonly [K in keyof T]: WithDisconnected<T[K]> }
|
|
285
|
+
|
|
286
|
+
const isSession = (self: Resumable): self is Session<any, any, any, any> => "send" in self
|
|
287
|
+
|
|
288
|
+
const isResumable = (u: unknown): u is Resumable =>
|
|
289
|
+
Predicate.hasProperty(u, "send") || Predicate.hasProperty(u, "resume")
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Resume a dropped event stream from the last seq, as `HttpClient.retryTransient`
|
|
293
|
+
* does for requests. Retries drops (`Client.Disconnected`), timeouts, transport
|
|
294
|
+
* errors, 408 / 429 / 5xx and an SSE `retry:` line, waiting at least what the
|
|
295
|
+
* server asks for (`Retry-After`, `retry:`); anything else (404, 401, a
|
|
296
|
+
* malformed frame) ends the stream with that error. `while` adds the app's own
|
|
297
|
+
* transient errors (e.g. a dropped WebSocket).
|
|
298
|
+
*
|
|
299
|
+
* A session is followed forever; a durable run until its terminal. A durable
|
|
300
|
+
* `submit` is never retried, so an input is never sent twice. Not for `direct`
|
|
301
|
+
* / `local`: there the run is the request, with no position to resume from.
|
|
302
|
+
*
|
|
303
|
+
* const transport = Transport.reconnect(Transport.sessionHttp({ send, events, stop }))
|
|
304
|
+
* const ws = pipe(Transport.session({ send, events }), Transport.reconnect({ while: isDropped }))
|
|
305
|
+
*/
|
|
306
|
+
export const reconnect: {
|
|
307
|
+
<E>(options?: ReconnectOptions<E>): <T extends Resumable>(self: T) => Reconnected<T>
|
|
308
|
+
<T extends Resumable>(self: T, options?: ReconnectOptions<ErrorOf<T>>): Reconnected<T>
|
|
309
|
+
} = Function.dual(
|
|
310
|
+
(args) => isResumable(args[0]),
|
|
311
|
+
(self: Resumable, options?: ReconnectOptions<unknown>): Resumable => {
|
|
312
|
+
const schedule = policy(options)
|
|
313
|
+
const resumed =
|
|
314
|
+
(events: Session<any, any, any, any>["events"], until: "terminal" | "never") =>
|
|
315
|
+
(streamId: StreamId, after: number) =>
|
|
316
|
+
Client.resumable((from) => events(streamId, from), { from: after, until, schedule })
|
|
317
|
+
return isSession(self)
|
|
318
|
+
? { ...self, events: resumed(self.events, "never") }
|
|
319
|
+
: durableOf({ ...self, resume: resumed(self.resume, "terminal") })
|
|
320
|
+
},
|
|
321
|
+
)
|