@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/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 { Effect, type Option, type Schedule, Stream } from "effect"
12
- import type { HttpClient, HttpClientRequest } from "effect/http"
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` (wrap it in `Client.resumable` to survive
74
- * drops). The run lives on the server, so a reload re-attaches with `resume`.
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
- start: (input, state) =>
82
- Stream.unwrap(
83
- options.submit(input, state).pipe(Effect.map((streamId) => options.events(streamId, 0))),
84
- ),
85
- resume: options.events,
86
- ...(options.stop !== undefined ? { stop: options.stop } : {}),
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` (wrap it in
94
- * `Client.resumable` with `until: "never"`). The run arrives on that stream,
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> => options
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 that reconnect
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 and reconnects per `reconnect`.
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. It carries many runs and never ends on its own, so
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
+ )