@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.
@@ -1,8 +1,10 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { StreamId } from "./RunEvent.mjs";
3
- import { execute, ndjson, resumable, sse } from "./Client.mjs";
3
+ import { Disconnected, execute, ndjson, resumable, sse } from "./Client.mjs";
4
4
  import { sequence } from "./Run.mjs";
5
- import { Effect, Stream } from "effect";
5
+ import { DateTime, Duration, Effect, Function, Option, Predicate, Schedule, Schema, Stream } from "effect";
6
+ import { Sse } from "effect/encoding";
7
+ import { HttpClient, HttpClientError } from "effect/http";
6
8
  //#region src/Transport.ts
7
9
  /**
8
10
  * How a client reaches the server. Every transport turns an input into the
@@ -19,6 +21,7 @@ var Transport_exports = /* @__PURE__ */ __exportAll({
19
21
  durable: () => durable,
20
22
  durableHttp: () => durableHttp,
21
23
  local: () => local,
24
+ reconnect: () => reconnect,
22
25
  session: () => session,
23
26
  sessionHttp: () => sessionHttp
24
27
  });
@@ -36,47 +39,104 @@ const direct = (options) => ({ start: (input, state) => Stream.unwrap(options.re
36
39
  * run's events are sequenced under `streamId`.
37
40
  */
38
41
  const local = (run, options) => ({ start: (input, state) => run(input, state).pipe(sequence(options?.streamId ?? StreamId.make("local"))) });
42
+ const durableOf = (parts) => ({
43
+ ...parts,
44
+ start: (input, state) => Stream.unwrap(Effect.map(parts.submit(input, state), (streamId) => parts.resume(streamId, 0)))
45
+ });
39
46
  /**
40
47
  * Durable runs: `submit` starts one on the server and returns its stream id;
41
- * `events` reads it from `after` (wrap it in `Client.resumable` to survive
42
- * drops). The run lives on the server, so a reload re-attaches with `resume`.
48
+ * `events` reads it from `after`. The run lives on the server, so a reload
49
+ * re-attaches with `resume`. A stream that ends before the run's terminal
50
+ * fails with `Client.Disconnected`; pipe through `reconnect` to resume it.
43
51
  */
44
- const durable = (options) => ({
45
- start: (input, state) => Stream.unwrap(options.submit(input, state).pipe(Effect.map((streamId) => options.events(streamId, 0)))),
46
- resume: options.events,
52
+ const durable = (options) => durableOf({
53
+ submit: options.submit,
54
+ resume: (streamId, after) => resumable((from) => options.events(streamId, from), { from: after }),
47
55
  ...options.stop !== void 0 ? { stop: options.stop } : {}
48
56
  });
49
- const session = (options) => options;
57
+ /**
58
+ * A session stream never ends on its own, so one that completes fails with
59
+ * `Client.Disconnected`; pipe through `reconnect` to resume it.
60
+ */
61
+ const session = (options) => ({
62
+ ...options,
63
+ events: (streamId, after) => resumable((from) => options.events(streamId, from), {
64
+ from: after,
65
+ until: "never"
66
+ })
67
+ });
50
68
  const stopOver = (stop) => stop === void 0 ? {} : { stop: (streamId) => execute(stop(streamId)).pipe(Effect.asVoid) };
51
69
  /**
52
70
  * `durable` over HTTP. `submit` starts a run and returns its stream id (the
53
71
  * app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);
54
- * `events` is read as SSE and reconnects per `reconnect`.
72
+ * `events` is read as SSE. Pipe through `reconnect` to survive drops.
55
73
  */
56
74
  const durableHttp = (options) => durable({
57
75
  submit: options.submit,
58
- events: (streamId, after) => resumable((from) => sse(options.events(streamId, from)), {
59
- from: after,
60
- schedule: options.reconnect
61
- }),
76
+ events: (streamId, after) => sse(options.events(streamId, after)),
62
77
  ...stopOver(options.stop)
63
78
  });
64
79
  /**
65
80
  * `session` over HTTP. `send` adds an input and returns the session's stream
66
81
  * id (without one, the server starts a session); `events` follows the whole
67
- * session as SSE. It carries many runs and never ends on its own, so
68
- * `reconnect` usually retries forever (e.g. `Schedule.spaced("1 second")`).
82
+ * session as SSE. Pipe through `reconnect` to survive drops.
69
83
  */
70
84
  const sessionHttp = (options) => session({
71
85
  send: options.send,
72
- events: (streamId, after) => resumable((from) => sse(options.events(streamId, from)), {
73
- from: after,
74
- until: "never",
75
- schedule: options.reconnect
76
- }),
86
+ events: (streamId, after) => sse(options.events(streamId, after)),
77
87
  ...stopOver(options.stop)
78
88
  });
89
+ const defaultSchedule = Schedule.min([Schedule.exponential("500 millis"), Schedule.spaced("10 seconds")]).pipe(Schedule.jittered);
90
+ const isDisconnected = (error) => error instanceof Disconnected;
91
+ const isTransient = (error) => Sse.Retry.is(error) || HttpClient.isTransientError(error);
92
+ const parseSeconds = Schema.decodeUnknownOption(Schema.FiniteFromString);
93
+ const retryAfter = (value, now) => parseSeconds(value).pipe(Option.map((seconds) => Duration.seconds(Math.max(0, seconds))), Option.orElse(() => DateTime.make(value).pipe(Option.map((at) => Duration.millis(Math.max(0, DateTime.toEpochMillis(at) - now))))));
94
+ const serverDelay = (error, now) => {
95
+ if (Sse.Retry.is(error)) return Option.some(error.duration);
96
+ if (!HttpClientError.isHttpClientError(error)) return Option.none();
97
+ return Option.fromNullishOr(error.response?.headers["retry-after"]).pipe(Option.flatMap((value) => retryAfter(value, now)));
98
+ };
99
+ const policy = (options) => {
100
+ const base = options?.schedule ?? defaultSchedule;
101
+ const extra = options?.while;
102
+ return (options?.times === void 0 ? base : Schedule.upTo(base, { times: options.times })).pipe(Schedule.while(({ input }) => isDisconnected(input) || isTransient(input) || extra !== void 0 && extra(input)), Schedule.modifyDelay(({ input, duration, now }) => Effect.succeed(Option.match(serverDelay(input, now), {
103
+ onNone: () => duration,
104
+ onSome: (asked) => Duration.max(duration, asked)
105
+ }))));
106
+ };
107
+ const isSession = (self) => "send" in self;
108
+ const isResumable = (u) => Predicate.hasProperty(u, "send") || Predicate.hasProperty(u, "resume");
109
+ /**
110
+ * Resume a dropped event stream from the last seq, as `HttpClient.retryTransient`
111
+ * does for requests. Retries drops (`Client.Disconnected`), timeouts, transport
112
+ * errors, 408 / 429 / 5xx and an SSE `retry:` line, waiting at least what the
113
+ * server asks for (`Retry-After`, `retry:`); anything else (404, 401, a
114
+ * malformed frame) ends the stream with that error. `while` adds the app's own
115
+ * transient errors (e.g. a dropped WebSocket).
116
+ *
117
+ * A session is followed forever; a durable run until its terminal. A durable
118
+ * `submit` is never retried, so an input is never sent twice. Not for `direct`
119
+ * / `local`: there the run is the request, with no position to resume from.
120
+ *
121
+ * const transport = Transport.reconnect(Transport.sessionHttp({ send, events, stop }))
122
+ * const ws = pipe(Transport.session({ send, events }), Transport.reconnect({ while: isDropped }))
123
+ */
124
+ const reconnect = Function.dual((args) => isResumable(args[0]), (self, options) => {
125
+ const schedule = policy(options);
126
+ const resumed = (events, until) => (streamId, after) => resumable((from) => events(streamId, from), {
127
+ from: after,
128
+ until,
129
+ schedule
130
+ });
131
+ return isSession(self) ? {
132
+ ...self,
133
+ events: resumed(self.events, "never")
134
+ } : durableOf({
135
+ ...self,
136
+ resume: resumed(self.resume, "terminal")
137
+ });
138
+ });
79
139
  //#endregion
80
- export { direct, durable, durableHttp, local, session, sessionHttp, Transport_exports as t };
140
+ export { direct, durable, durableHttp, local, reconnect, session, sessionHttp, Transport_exports as t };
81
141
 
82
142
  //# sourceMappingURL=Transport.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"Transport.mjs","names":["Client.ndjson","Client.sse","Run.sequence","Client.execute","Client.resumable"],"sources":["../src/Transport.ts"],"sourcesContent":["/**\n * How a client reaches the server. Every transport turns an input into the\n * envelopes of the run it starts; durable transports can also re-attach to a\n * run and stop it. Missing capabilities are absent from the value, so a UI\n * can tell whether stop or resume exists.\n *\n * `I` is the app's input (`Input.Input` for the preset); `S` is the client\n * state passed to `start`, so a request can carry any app data from it (a\n * chat id, settings).\n */\nimport { Effect, type Option, type Schedule, Stream } from \"effect\"\nimport type { HttpClient, HttpClientRequest } from \"effect/http\"\nimport * as Client from \"./Client.js\"\nimport * as Run from \"./Run.js\"\nimport { type Envelope, type RunEvent, StreamId } from \"./RunEvent.js\"\n\nexport type Transport<I, S, E, R> = {\n /** Start a run for `input` and stream its envelopes. Interrupting the stream abandons it. */\n readonly start: (input: I, state: S) => Stream.Stream<Envelope, E, R>\n /** Re-attach to a run after a reload, from `after`. Durable only. */\n readonly resume?: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n /** Ask the server to stop a run. Durable only; direct mode stops by interrupting `start`. */\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\n/**\n * Direct mode: one HTTP request per input whose response body is the run.\n * Stop interrupts the stream, which aborts the request; the server sees the\n * client go away and interrupts its run.\n *\n * Any other source (RPC streams, server functions) is a transport too:\n * write `{ start: (input, state) => stream }` directly.\n */\nexport const direct = <I, S, EB = never, RB = never>(options: {\n readonly request: (\n input: I,\n state: S,\n ) => Effect.Effect<HttpClientRequest.HttpClientRequest, EB, RB>\n readonly format?: \"sse\" | \"ndjson\"\n}): Transport<I, S, Client.ClientError | EB, HttpClient.HttpClient | RB> => ({\n start: (input, state) =>\n Stream.unwrap(\n options\n .request(input, state)\n .pipe(\n Effect.map(\n (request): Stream.Stream<Envelope, Client.ClientError, HttpClient.HttpClient> =>\n options.format === \"ndjson\" ? Client.ndjson(request) : Client.sse(request),\n ),\n ),\n ),\n})\n\n/**\n * Run the agent in the same process (browser agent, tests, Storybook). The\n * run's events are sequenced under `streamId`.\n */\nexport const local = <I, S, E, R>(\n run: (input: I, state: S) => Stream.Stream<RunEvent, E, R>,\n options?: { readonly streamId?: StreamId },\n): Transport<I, S, E, R> => ({\n start: (input, state) =>\n run(input, state).pipe(Run.sequence(options?.streamId ?? StreamId.make(\"local\"))),\n})\n\n/** A per-run transport that can re-attach to a run (durable). */\nexport type Durable<I, S, E, R> = Transport<I, S, E, R> & {\n readonly resume: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n}\n\n/**\n * Durable runs: `submit` starts one on the server and returns its stream id;\n * `events` reads it from `after` (wrap it in `Client.resumable` to survive\n * drops). The run lives on the server, so a reload re-attaches with `resume`.\n */\nexport const durable = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Durable<I, S, ES | EE | EX, RS | RE | RX> => ({\n start: (input, state) =>\n Stream.unwrap(\n options.submit(input, state).pipe(Effect.map((streamId) => options.events(streamId, 0))),\n ),\n resume: options.events,\n ...(options.stop !== undefined ? { stop: options.stop } : {}),\n})\n\n/**\n * A durable session (a chat): one stream carries every run, and the server\n * owns its history. `send` without a stream id starts a new session and\n * returns its id (the app's own chat id, branded); with one, it adds a run to\n * that session. `events` follows the whole session from `after` (wrap it in\n * `Client.resumable` with `until: \"never\"`). The run arrives on that stream,\n * not as a response to `send`.\n */\nexport type Session<I, S, E, R> = {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, E, R>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\nexport const session = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Session<I, S, ES | EE | EX, RS | RE | RX> => options\n\n// ---------------------------------------------------------------------------\n// Over HTTP: `durable` and `session` with SSE events that reconnect\n// ---------------------------------------------------------------------------\n\n/** What the HTTP helpers can fail with. */\nexport type HttpError = Client.ClientError | Client.Disconnected\n\ntype HttpOptions = {\n /** The SSE request for a stream from `after`. */\n readonly events: (streamId: StreamId, after: number) => HttpClientRequest.HttpClientRequest\n /** The request that stops a stream's active run. */\n readonly stop?: (streamId: StreamId) => HttpClientRequest.HttpClientRequest\n /** When to reconnect a dropped event stream (it resumes from the last `seq`). */\n readonly reconnect: Schedule.Schedule<unknown, HttpError>\n}\n\nconst stopOver = (stop: HttpOptions[\"stop\"]) =>\n stop === undefined\n ? {}\n : { stop: (streamId: StreamId) => Client.execute(stop(streamId)).pipe(Effect.asVoid) }\n\n/**\n * `durable` over HTTP. `submit` starts a run and returns its stream id (the\n * app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);\n * `events` is read as SSE and reconnects per `reconnect`.\n */\nexport const durableHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n },\n): Durable<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n durable({\n submit: options.submit,\n events: (streamId, after) =>\n Client.resumable((from) => Client.sse(options.events(streamId, from)), {\n from: after,\n schedule: options.reconnect,\n }),\n ...stopOver(options.stop),\n })\n\n/**\n * `session` over HTTP. `send` adds an input and returns the session's stream\n * id (without one, the server starts a session); `events` follows the whole\n * session as SSE. It carries many runs and never ends on its own, so\n * `reconnect` usually retries forever (e.g. `Schedule.spaced(\"1 second\")`).\n */\nexport const sessionHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n },\n): Session<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n session({\n send: options.send,\n events: (streamId, after) =>\n Client.resumable((from) => Client.sse(options.events(streamId, from)), {\n from: after,\n until: \"never\",\n schedule: options.reconnect,\n }),\n ...stopOver(options.stop),\n })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,UAAwC,aAMwB,EAC3E,QAAQ,OAAO,UACb,OAAO,OACL,QACG,QAAQ,OAAO,KAAK,CAAC,CACrB,KACC,OAAO,KACJ,YACC,QAAQ,WAAW,WAAWA,OAAc,OAAO,IAAIC,IAAW,OAAO,CAC7E,CACF,CACJ,EACJ;;;;;AAMA,MAAa,SACX,KACA,aAC2B,EAC3B,QAAQ,OAAO,UACb,IAAI,OAAO,KAAK,CAAC,CAAC,KAAKC,SAAa,SAAS,YAAY,SAAS,KAAK,OAAO,CAAC,CAAC,EACpF;;;;;;AAYA,MAAa,WAAyD,aAIpB;CAChD,QAAQ,OAAO,UACb,OAAO,OACL,QAAQ,OAAO,OAAO,KAAK,CAAC,CAAC,KAAK,OAAO,KAAK,aAAa,QAAQ,OAAO,UAAU,CAAC,CAAC,CAAC,CACzF;CACF,QAAQ,QAAQ;CAChB,GAAI,QAAQ,SAAS,KAAA,IAAY,EAAE,MAAM,QAAQ,KAAK,IAAI,CAAC;AAC7D;AAoBA,MAAa,WAAyD,YAQrB;AAkBjD,MAAM,YAAY,SAChB,SAAS,KAAA,IACL,CAAC,IACD,EAAE,OAAO,aAAuBC,QAAe,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,OAAO,MAAM,EAAE;;;;;;AAOzF,MAAa,eACX,YAIA,QAAQ;CACN,QAAQ,QAAQ;CAChB,SAAS,UAAU,UACjBC,WAAkB,SAASH,IAAW,QAAQ,OAAO,UAAU,IAAI,CAAC,GAAG;EACrE,MAAM;EACN,UAAU,QAAQ;CACpB,CAAC;CACH,GAAG,SAAS,QAAQ,IAAI;AAC1B,CAAC;;;;;;;AAQH,MAAa,eACX,YAQA,QAAQ;CACN,MAAM,QAAQ;CACd,SAAS,UAAU,UACjBG,WAAkB,SAASH,IAAW,QAAQ,OAAO,UAAU,IAAI,CAAC,GAAG;EACrE,MAAM;EACN,OAAO;EACP,UAAU,QAAQ;CACpB,CAAC;CACH,GAAG,SAAS,QAAQ,IAAI;AAC1B,CAAC"}
1
+ {"version":3,"file":"Transport.mjs","names":["Client.ndjson","Client.sse","Run.sequence","Client.resumable","Client.execute","Client.Disconnected"],"sources":["../src/Transport.ts"],"sourcesContent":["/**\n * How a client reaches the server. Every transport turns an input into the\n * envelopes of the run it starts; durable transports can also re-attach to a\n * run and stop it. Missing capabilities are absent from the value, so a UI\n * can tell whether stop or resume exists.\n *\n * `I` is the app's input (`Input.Input` for the preset); `S` is the client\n * state passed to `start`, so a request can carry any app data from it (a\n * chat id, settings).\n */\nimport {\n DateTime,\n Duration,\n Effect,\n Option,\n Function,\n Predicate,\n Schedule,\n Schema,\n Stream,\n} from \"effect\"\nimport { Sse } from \"effect/encoding\"\nimport { HttpClient, HttpClientError, type HttpClientRequest } from \"effect/http\"\nimport * as Client from \"./Client.js\"\nimport * as Run from \"./Run.js\"\nimport { type Envelope, type RunEvent, StreamId } from \"./RunEvent.js\"\n\nexport type Transport<I, S, E, R> = {\n /** Start a run for `input` and stream its envelopes. Interrupting the stream abandons it. */\n readonly start: (input: I, state: S) => Stream.Stream<Envelope, E, R>\n /** Re-attach to a run after a reload, from `after`. Durable only. */\n readonly resume?: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n /** Ask the server to stop a run. Durable only; direct mode stops by interrupting `start`. */\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\n/**\n * Direct mode: one HTTP request per input whose response body is the run.\n * Stop interrupts the stream, which aborts the request; the server sees the\n * client go away and interrupts its run.\n *\n * Any other source (RPC streams, server functions) is a transport too:\n * write `{ start: (input, state) => stream }` directly.\n */\nexport const direct = <I, S, EB = never, RB = never>(options: {\n readonly request: (\n input: I,\n state: S,\n ) => Effect.Effect<HttpClientRequest.HttpClientRequest, EB, RB>\n readonly format?: \"sse\" | \"ndjson\"\n}): Transport<I, S, Client.ClientError | EB, HttpClient.HttpClient | RB> => ({\n start: (input, state) =>\n Stream.unwrap(\n options\n .request(input, state)\n .pipe(\n Effect.map(\n (request): Stream.Stream<Envelope, Client.ClientError, HttpClient.HttpClient> =>\n options.format === \"ndjson\" ? Client.ndjson(request) : Client.sse(request),\n ),\n ),\n ),\n})\n\n/**\n * Run the agent in the same process (browser agent, tests, Storybook). The\n * run's events are sequenced under `streamId`.\n */\nexport const local = <I, S, E, R>(\n run: (input: I, state: S) => Stream.Stream<RunEvent, E, R>,\n options?: { readonly streamId?: StreamId },\n): Transport<I, S, E, R> => ({\n start: (input, state) =>\n run(input, state).pipe(Run.sequence(options?.streamId ?? StreamId.make(\"local\"))),\n})\n\n/** A per-run transport that can re-attach to a run (durable). `start` is `submit`, then `resume` from 0. */\nexport type Durable<I, S, E, R> = Transport<I, S, E, R> & {\n /** Start a run on the server and return its stream id. */\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, E, R>\n readonly resume: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n}\n\nconst durableOf = <I, S, E, R>(parts: Omit<Durable<I, S, E, R>, \"start\">): Durable<I, S, E, R> => ({\n ...parts,\n start: (input, state) =>\n Stream.unwrap(Effect.map(parts.submit(input, state), (streamId) => parts.resume(streamId, 0))),\n})\n\n/**\n * Durable runs: `submit` starts one on the server and returns its stream id;\n * `events` reads it from `after`. The run lives on the server, so a reload\n * re-attaches with `resume`. A stream that ends before the run's terminal\n * fails with `Client.Disconnected`; pipe through `reconnect` to resume it.\n */\nexport const durable = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Durable<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX> =>\n durableOf<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX>({\n submit: options.submit,\n resume: (streamId, after) =>\n Client.resumable((from) => options.events(streamId, from), { from: after }),\n ...(options.stop !== undefined ? { stop: options.stop } : {}),\n })\n\n/**\n * A durable session (a chat): one stream carries every run, and the server\n * owns its history. `send` without a stream id starts a new session and\n * returns its id (the app's own chat id, branded); with one, it adds a run to\n * that session. `events` follows the whole session from `after`. The run\n * arrives on that stream, not as a response to `send`.\n */\nexport type Session<I, S, E, R> = {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, E, R>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, E, R>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, E, R>\n}\n\n/**\n * A session stream never ends on its own, so one that completes fails with\n * `Client.Disconnected`; pipe through `reconnect` to resume it.\n */\nexport const session = <I, S, ES, RS, EE, RE, EX = never, RX = never>(options: {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n readonly events: (streamId: StreamId, after: number) => Stream.Stream<Envelope, EE, RE>\n readonly stop?: (streamId: StreamId) => Effect.Effect<void, EX, RX>\n}): Session<I, S, ES | EE | EX | Client.Disconnected, RS | RE | RX> => ({\n ...options,\n events: (streamId, after) =>\n Client.resumable((from) => options.events(streamId, from), { from: after, until: \"never\" }),\n})\n\n// ---------------------------------------------------------------------------\n// Over HTTP: `durable` and `session` with SSE events\n// ---------------------------------------------------------------------------\n\n/** What the HTTP helpers can fail with. */\nexport type HttpError = Client.ClientError | Client.Disconnected\n\ntype HttpOptions = {\n /** The SSE request for a stream from `after`. */\n readonly events: (streamId: StreamId, after: number) => HttpClientRequest.HttpClientRequest\n /** The request that stops a stream's active run. */\n readonly stop?: (streamId: StreamId) => HttpClientRequest.HttpClientRequest\n}\n\nconst stopOver = (stop: HttpOptions[\"stop\"]) =>\n stop === undefined\n ? {}\n : { stop: (streamId: StreamId) => Client.execute(stop(streamId)).pipe(Effect.asVoid) }\n\n/**\n * `durable` over HTTP. `submit` starts a run and returns its stream id (the\n * app decodes its own response, e.g. with `HttpClientResponse.schemaJson`);\n * `events` is read as SSE. Pipe through `reconnect` to survive drops.\n */\nexport const durableHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly submit: (input: I, state: S) => Effect.Effect<StreamId, ES, RS>\n },\n): Durable<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n durable({\n submit: options.submit,\n events: (streamId, after) => Client.sse(options.events(streamId, after)),\n ...stopOver(options.stop),\n })\n\n/**\n * `session` over HTTP. `send` adds an input and returns the session's stream\n * id (without one, the server starts a session); `events` follows the whole\n * session as SSE. Pipe through `reconnect` to survive drops.\n */\nexport const sessionHttp = <I, S, ES = never, RS = never>(\n options: HttpOptions & {\n readonly send: (\n streamId: Option.Option<StreamId>,\n input: I,\n state: S,\n ) => Effect.Effect<StreamId, ES, RS>\n },\n): Session<I, S, HttpError | ES, HttpClient.HttpClient | RS> =>\n session({\n send: options.send,\n events: (streamId, after) => Client.sse(options.events(streamId, after)),\n ...stopOver(options.stop),\n })\n\n// ---------------------------------------------------------------------------\n// Reconnect: resume a dropped event stream from the last seq\n// ---------------------------------------------------------------------------\n\nexport type ReconnectOptions<E> = {\n /**\n * When to retry. Default: exponential from 500 ms, capped at 10 s, jittered.\n * Cap your own exponential: the schedule resets only when a frame arrives,\n * so an idle session's backoff otherwise grows without bound.\n */\n readonly schedule?: Schedule.Schedule<unknown, E | Client.Disconnected>\n /** More errors to treat as transient, on top of drops, timeouts, 408 / 429 / 5xx and SSE `retry:`. */\n readonly while?: Predicate.Predicate<E>\n /** Max attempts in a row; the count resets once a frame arrives. Default: unlimited. */\n readonly times?: number\n}\n\nconst defaultSchedule = Schedule.min([\n Schedule.exponential(\"500 millis\"),\n Schedule.spaced(\"10 seconds\"),\n]).pipe(Schedule.jittered)\n\nconst isDisconnected = (error: unknown): error is Client.Disconnected =>\n error instanceof Client.Disconnected\n\nconst isTransient = (error: unknown) => Sse.Retry.is(error) || HttpClient.isTransientError(error)\n\nconst parseSeconds = Schema.decodeUnknownOption(Schema.FiniteFromString)\n\n// `Retry-After` is either seconds or an HTTP date.\nconst retryAfter = (value: string, now: number): Option.Option<Duration.Duration> =>\n parseSeconds(value).pipe(\n Option.map((seconds) => Duration.seconds(Math.max(0, seconds))),\n Option.orElse(() =>\n DateTime.make(value).pipe(\n Option.map((at) => Duration.millis(Math.max(0, DateTime.toEpochMillis(at) - now))),\n ),\n ),\n )\n\n// The wait the server asked for: an SSE `retry:` line or a `Retry-After` header.\nconst serverDelay = (error: unknown, now: number): Option.Option<Duration.Duration> => {\n if (Sse.Retry.is(error)) return Option.some(error.duration)\n if (!HttpClientError.isHttpClientError(error)) return Option.none()\n return Option.fromNullishOr(error.response?.headers[\"retry-after\"]).pipe(\n Option.flatMap((value) => retryAfter(value, now)),\n )\n}\n\nconst policy = <E>(\n options: ReconnectOptions<E> | undefined,\n): Schedule.Schedule<unknown, E | Client.Disconnected> => {\n const base = options?.schedule ?? defaultSchedule\n const extra = options?.while\n return (options?.times === undefined ? base : Schedule.upTo(base, { times: options.times })).pipe(\n Schedule.while(\n ({ input }) =>\n isDisconnected(input) || isTransient(input) || (extra !== undefined && extra(input)),\n ),\n Schedule.modifyDelay(({ input, duration, now }) =>\n Effect.succeed(\n Option.match(serverDelay(input, now), {\n onNone: () => duration,\n onSome: (asked) => Duration.max(duration, asked),\n }),\n ),\n ),\n )\n}\n\n/** A transport `reconnect` applies to: one with a position to resume from. */\nexport type Resumable = Session<any, any, any, any> | Durable<any, any, any, any>\n\n/** What a transport can fail with. */\nexport type ErrorOf<T extends Resumable> =\n T extends Session<any, any, infer E, any>\n ? E\n : T extends Durable<any, any, infer E, any>\n ? E\n : never\n\ntype WithDisconnected<F> = F extends (...args: infer A) => Stream.Stream<infer X, infer E, infer R>\n ? (...args: A) => Stream.Stream<X, E | Client.Disconnected, R>\n : F\n\n/** `T` with `Client.Disconnected` added to its streams; `send` / `submit` keep their exact types. */\nexport type Reconnected<T extends Resumable> = { readonly [K in keyof T]: WithDisconnected<T[K]> }\n\nconst isSession = (self: Resumable): self is Session<any, any, any, any> => \"send\" in self\n\nconst isResumable = (u: unknown): u is Resumable =>\n Predicate.hasProperty(u, \"send\") || Predicate.hasProperty(u, \"resume\")\n\n/**\n * Resume a dropped event stream from the last seq, as `HttpClient.retryTransient`\n * does for requests. Retries drops (`Client.Disconnected`), timeouts, transport\n * errors, 408 / 429 / 5xx and an SSE `retry:` line, waiting at least what the\n * server asks for (`Retry-After`, `retry:`); anything else (404, 401, a\n * malformed frame) ends the stream with that error. `while` adds the app's own\n * transient errors (e.g. a dropped WebSocket).\n *\n * A session is followed forever; a durable run until its terminal. A durable\n * `submit` is never retried, so an input is never sent twice. Not for `direct`\n * / `local`: there the run is the request, with no position to resume from.\n *\n * const transport = Transport.reconnect(Transport.sessionHttp({ send, events, stop }))\n * const ws = pipe(Transport.session({ send, events }), Transport.reconnect({ while: isDropped }))\n */\nexport const reconnect: {\n <E>(options?: ReconnectOptions<E>): <T extends Resumable>(self: T) => Reconnected<T>\n <T extends Resumable>(self: T, options?: ReconnectOptions<ErrorOf<T>>): Reconnected<T>\n} = Function.dual(\n (args) => isResumable(args[0]),\n (self: Resumable, options?: ReconnectOptions<unknown>): Resumable => {\n const schedule = policy(options)\n const resumed =\n (events: Session<any, any, any, any>[\"events\"], until: \"terminal\" | \"never\") =>\n (streamId: StreamId, after: number) =>\n Client.resumable((from) => events(streamId, from), { from: after, until, schedule })\n return isSession(self)\n ? { ...self, events: resumed(self.events, \"never\") }\n : durableOf({ ...self, resume: resumed(self.resume, \"terminal\") })\n },\n)\n"],"mappings":"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"}
package/dist/index.d.mts CHANGED
@@ -2,7 +2,7 @@ import { t as RunEvent_d_exports } from "./RunEvent.mjs";
2
2
  import { s as Input_d_exports } from "./Input-Cz-Kxusm.mjs";
3
3
  import { u as RunView_d_exports } from "./RunView-nHfJwN8t.mjs";
4
4
  import { l as Thread_d_exports } from "./Thread-CAXsjhpX.mjs";
5
- import { n as Client_d_exports } from "./Client-C9UYN_VK.mjs";
5
+ import { n as Client_d_exports } from "./Client-B2Eqo39q.mjs";
6
6
  import { t as Transport_d_exports } from "./Transport.mjs";
7
7
  import { t as UiContract_d_exports } from "./UiContract.mjs";
8
8
  import { t as Atoms_d_exports } from "./Atoms.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/ui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0-dev-20261110202152",
4
4
  "description": "Agentic UI primitives for @effect-uai/core: a resumable run-event protocol, projection, client folds and Effect atoms.",
5
5
  "keywords": [
6
6
  "agents",
@@ -89,11 +89,11 @@
89
89
  "@effect/vitest": "4.1.2",
90
90
  "effect": "4.1.2",
91
91
  "typescript": "^7.0.2",
92
- "@effect-uai/core": "0.19.0"
92
+ "@effect-uai/core": "0.20.0-dev-20261110202152"
93
93
  },
94
94
  "peerDependencies": {
95
- "@effect-uai/core": ">=0.17.0 <1",
96
- "effect": ">=4.0.1 <5.0.0"
95
+ "@effect-uai/core": "0.20.0-dev-20261110202152",
96
+ "effect": ">=4.1.0 <5.0.0"
97
97
  },
98
98
  "scripts": {
99
99
  "build": "tsdown",
package/src/Atoms.test.ts CHANGED
@@ -160,4 +160,34 @@ describe("Atoms.sessionThread", () => {
160
160
  expect(replayed.messages.map((m) => m.role)).toEqual(["assistant", "assistant"])
161
161
  }),
162
162
  )
163
+
164
+ it.effect("a hydrated thread starts as `initial` and opens from its last seq", () =>
165
+ Effect.gen(function* () {
166
+ const { chatId, transport } = yield* fakeSession
167
+ const first = Atoms.sessionThread({ runtime, transport })
168
+ const elsewhere = AtomRegistry.make()
169
+ elsewhere.set(first.submit, Input.submit("first"))
170
+ const hydrated = yield* settledCount(elsewhere, first.state, 2)
171
+ elsewhere.set(first.submit, Input.submit("second"))
172
+ yield* settledCount(elsewhere, first.state, 4)
173
+
174
+ const opened = yield* Ref.make<ReadonlyArray<number>>([])
175
+ const spied = Transport.session({
176
+ send: transport.send,
177
+ events: (id, after) =>
178
+ Stream.unwrap(
179
+ Ref.update(opened, (a) => [...a, after]).pipe(Effect.as(transport.events(id, after))),
180
+ ),
181
+ })
182
+ const chat = Atoms.sessionThread({ runtime, transport: spied, initial: hydrated })
183
+ const registry = AtomRegistry.make()
184
+ expect(registry.get(chat.state)).toEqual(hydrated)
185
+ expect(registry.get(chat.streamId)).toBeNull()
186
+
187
+ registry.set(chat.open, { streamId: chatId, after: hydrated.lastSeq })
188
+ const thread = yield* settledCount(registry, chat.state, 3)
189
+ expect(yield* Ref.get(opened)).toEqual([hydrated.lastSeq])
190
+ expect(thread.messages.map((m) => m.role)).toEqual(["user", "assistant", "assistant"])
191
+ }),
192
+ )
163
193
  })
package/src/Atoms.ts CHANGED
@@ -234,6 +234,9 @@ type BaseSessionOptions<I, S, E, R, ER, D> = {
234
234
  export type SessionOptions<I, S, E, R, ER, D = Envelope> = BaseSessionOptions<I, S, E, R, ER, D> &
235
235
  WithDecode<D>
236
236
 
237
+ /** What `open` follows: a session from its start, or from `after` (e.g. a hydrated thread's `lastSeq`). */
238
+ export type Open = StreamId | { readonly streamId: StreamId; readonly after: number }
239
+
237
240
  const buildSession = <I, S, E, R, ER, D>(
238
241
  options: BaseSessionOptions<I, S, E, R, ER, D> & { readonly decode: Decode<D> },
239
242
  ) => {
@@ -249,11 +252,13 @@ const buildSession = <I, S, E, R, ER, D>(
249
252
 
250
253
  // Non-concurrent: opening another session stops following the previous one.
251
254
  const open = options.runtime
252
- .fn<StreamId>()((id) =>
253
- Atom.set(streamId, id).pipe(
255
+ .fn<Open>()((target) => {
256
+ const { streamId: id, after } =
257
+ typeof target === "string" ? { streamId: target, after: 0 } : target
258
+ return Atom.set(streamId, id).pipe(
254
259
  Effect.andThen(
255
260
  options.transport
256
- .events(id, 0)
261
+ .events(id, after)
257
262
  .pipe(
258
263
  Stream.runForEach((envelope) =>
259
264
  options
@@ -267,8 +272,8 @@ const buildSession = <I, S, E, R, ER, D>(
267
272
  ),
268
273
  ),
269
274
  Effect.onExit(settle),
270
- ),
271
- )
275
+ )
276
+ })
272
277
  .pipe(Atom.keepAlive)
273
278
 
274
279
  const submit = options.runtime
@@ -305,7 +310,9 @@ type BuiltSession<I, S, E, R, ER> = ReturnType<typeof buildSession<I, S, E, R, E
305
310
 
306
311
  /**
307
312
  * State over a session transport. `open(streamId)` follows a session's
308
- * stream from its start (e.g. a chat id kept across reloads). `submit` sends
313
+ * stream from its start (e.g. a chat id kept across reloads);
314
+ * `open({ streamId, after })` from a position, for state hydrated elsewhere
315
+ * (pass it as `initial`; `initial` never opens anything). `submit` sends
309
316
  * an input; without an open session the server starts one, which is then
310
317
  * opened. Runs arrive on the followed stream, not as responses to `submit`.
311
318
  */
@@ -322,6 +329,8 @@ type SessionThreadOptions<I, E, R, ER, P extends Payloads> = {
322
329
  readonly runtime: Runtime<R, ER>
323
330
  readonly transport: Transport.Session<Input.Sent<I>, Thread.Thread<P>, E, R>
324
331
  readonly isBusy?: (thread: Thread.Thread<P>) => boolean
332
+ /** A thread hydrated elsewhere (e.g. from a database); follow it with `open({ streamId, after: lastSeq })`. */
333
+ readonly initial?: Thread.Thread<P>
325
334
  }
326
335
 
327
336
  /**
@@ -345,7 +354,7 @@ export function sessionThread<I extends Input.Input, E, R, ER>(
345
354
  return buildSession({
346
355
  runtime: options.runtime,
347
356
  transport: options.transport,
348
- initial: fold.initial,
357
+ initial: options.initial ?? fold.initial,
349
358
  step: fold.step,
350
359
  decode: options.contract?.decode ?? identity,
351
360
  ...(options.isBusy !== undefined ? { isBusy: options.isBusy } : {}),
package/src/Client.ts CHANGED
@@ -42,26 +42,27 @@ export const ndjson = (request: HttpClientRequest.HttpClientRequest) =>
42
42
  * Keep events flowing across dropped connections: `connect(after)` is
43
43
  * re-invoked with the last seen `seq` on failure, per `schedule`; duplicates
44
44
  * are dropped. A connection that ends cleanly before the end counts as a
45
- * failure (`Disconnected`), so it is retried too.
45
+ * failure (`Disconnected`), so it is retried too. Without `schedule` nothing
46
+ * is retried: an early end just fails with `Disconnected`.
46
47
  *
47
48
  * `until: "terminal"` (default) follows one run and completes after its
48
49
  * terminal. `until: "never"` follows a session stream that carries many runs
49
50
  * (a durable chat) and never completes on its own.
50
51
  */
51
- export const resumable = <E, R, X>(
52
+ export const resumable = <E, R, X = never>(
52
53
  connect: (after: number) => Stream.Stream<Envelope, E, R>,
53
- options: {
54
+ options?: {
54
55
  readonly from?: number
55
56
  readonly until?: "terminal" | "never"
56
- readonly schedule: Schedule.Schedule<X, E | Disconnected>
57
+ readonly schedule?: Schedule.Schedule<X, E | Disconnected>
57
58
  },
58
59
  ): Stream.Stream<Envelope, E | Disconnected, R> =>
59
60
  Stream.unwrap(
60
61
  Effect.gen(function* () {
61
- const untilTerminal = options.until !== "never"
62
+ const untilTerminal = options?.until !== "never"
62
63
  const ends = (envelope: Envelope) => untilTerminal && isTerminal(envelope.event)
63
64
  // The last seen seq, and whether the run's terminal arrived.
64
- const position = yield* Ref.make({ seq: options.from ?? 0, done: false })
65
+ const position = yield* Ref.make({ seq: options?.from ?? 0, done: false })
65
66
  const attempt = Stream.unwrap(
66
67
  Ref.get(position).pipe(
67
68
  Effect.map(({ seq: after }) =>
@@ -87,6 +88,7 @@ export const resumable = <E, R, X>(
87
88
  ),
88
89
  ),
89
90
  )
90
- return attempt.pipe(Stream.retry(options.schedule))
91
+ const schedule = options?.schedule
92
+ return schedule === undefined ? attempt : attempt.pipe(Stream.retry(schedule))
91
93
  }),
92
94
  )
@@ -1,5 +1,6 @@
1
1
  import { describe, it } from "@effect/vitest"
2
- import { Effect, Option, Stream } from "effect"
2
+ import { Effect, Fiber, Option, Schedule, Stream } from "effect"
3
+ import { TestClock } from "effect/testing"
3
4
  import { expect } from "vitest"
4
5
  import { type Envelope, RunId, StreamId } from "./RunEvent.js"
5
6
  import * as SseEncoding from "./SseEncoding.js"
@@ -43,4 +44,22 @@ describe("SseEncoding", () => {
43
44
  ])("lastEventId reads $label", ({ headers, seq }) => {
44
45
  expect(SseEncoding.lastEventId(headers)).toEqual(seq)
45
46
  })
47
+
48
+ it.effect("heartbeat interleaves comments, ends with the events, and decodes away", () =>
49
+ Effect.gen(function* () {
50
+ const fiber = yield* Stream.fromIterable(envelopes).pipe(
51
+ Stream.schedule(Schedule.spaced("1 second")),
52
+ SseEncoding.encode,
53
+ SseEncoding.heartbeat("400 millis"),
54
+ Stream.runCollect,
55
+ Effect.forkChild,
56
+ )
57
+ yield* TestClock.adjust("3 seconds")
58
+ const bytes = yield* Fiber.join(fiber)
59
+ const text = new TextDecoder().decode(Buffer.concat(bytes))
60
+ expect(text.split(": heartbeat\n\n").length).toBeGreaterThan(2)
61
+ const decoded = yield* Stream.fromIterable(bytes).pipe(SseEncoding.decode, Stream.runCollect)
62
+ expect(decoded).toEqual(envelopes)
63
+ }),
64
+ )
46
65
  })
@@ -6,7 +6,7 @@
6
6
  * encoder and parser.
7
7
  * For NDJSON use `effect/encoding/Ndjson` with `RunEvent.EnvelopeJson`.
8
8
  */
9
- import { Effect, Option, Schema, SchemaTransformation, Stream } from "effect"
9
+ import { type Duration, Effect, Option, Schema, SchemaTransformation, Stream } from "effect"
10
10
  import { Sse } from "effect/encoding"
11
11
  import { Envelope, EnvelopeJson } from "./RunEvent.js"
12
12
 
@@ -50,6 +50,23 @@ export const decode = <E, R>(
50
50
  ): Stream.Stream<Envelope, E | Sse.Retry | Sse.SseError | Schema.SchemaError, R> =>
51
51
  self.pipe(Stream.decodeText, Stream.pipeThroughChannelOrFail(Sse.decodeSchema(EnvelopeEvent)))
52
52
 
53
+ const comment = new TextEncoder().encode(": heartbeat\n\n")
54
+
55
+ /**
56
+ * Interleave an SSE comment every `interval`, so proxies don't close a stream
57
+ * that sits idle (a session between answers). Pipe after `encode`: each frame
58
+ * is one chunk, so a comment always lands between whole frames. Parsers
59
+ * (`EventSource`, `decode`) ignore comments. Ends with the event stream.
60
+ */
61
+ export const heartbeat =
62
+ (interval: Duration.Input) =>
63
+ <E, R>(self: Stream.Stream<Uint8Array, E, R>): Stream.Stream<Uint8Array, E, R> =>
64
+ self.pipe(
65
+ Stream.merge(Stream.tick(interval).pipe(Stream.map(() => comment)), {
66
+ haltStrategy: "left",
67
+ }),
68
+ )
69
+
53
70
  /**
54
71
  * Headers an SSE response needs. Behind nginx-style proxies, also send
55
72
  * `x-accel-buffering: no` so frames are not buffered.
@@ -0,0 +1,199 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Data, Duration, Effect, Fiber, Function, Ref, Schedule, Stream } from "effect"
3
+ import { Sse } from "effect/encoding"
4
+ import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"
5
+ import { TestClock } from "effect/testing"
6
+ import { expect, expectTypeOf } from "vitest"
7
+ import * as Client from "./Client.js"
8
+ import { type Envelope, RunId, type RunEvent, StreamId } from "./RunEvent.js"
9
+ import * as Transport from "./Transport.js"
10
+
11
+ const r1 = RunId.make("r1")
12
+ const chatId = StreamId.make("chat")
13
+
14
+ class SocketDropped extends Data.TaggedError("SocketDropped")<{}> {}
15
+
16
+ type Failure = HttpClientError.HttpClientError | Sse.Retry | SocketDropped
17
+
18
+ const frames: ReadonlyArray<Envelope> = (
19
+ [
20
+ { type: "run-started", runId: r1, inputs: [], protocol: 2 },
21
+ { type: "text-delta", text: "a" },
22
+ { type: "text-delta", text: "b" },
23
+ { type: "text-delta", text: "c" },
24
+ { type: "run-completed" },
25
+ ] satisfies ReadonlyArray<RunEvent>
26
+ ).map((event, i) => ({ streamId: chatId, seq: i + 1, runId: r1, event }))
27
+
28
+ const status = (code: number, headers: Record<string, string> = {}) => {
29
+ const request = HttpClientRequest.get("/chats/chat/events")
30
+ const response = HttpClientResponse.fromWeb(
31
+ request,
32
+ new Response(null, { status: code, headers }),
33
+ )
34
+ return new HttpClientError.HttpClientError({
35
+ reason: new HttpClientError.StatusCodeError({ request, response }),
36
+ })
37
+ }
38
+
39
+ type Attempt = { readonly upTo: number; readonly error?: Failure }
40
+
41
+ // A server that replays from `after`; how each connection ends is scripted.
42
+ // Past the script, it sends the rest and then stays open (a live session).
43
+ const server = (attempts: ReadonlyArray<Attempt>) =>
44
+ Effect.gen(function* () {
45
+ const calls = yield* Ref.make<ReadonlyArray<number>>([])
46
+ const submits = yield* Ref.make(0)
47
+ const events = (_: StreamId, after: number): Stream.Stream<Envelope, Failure> =>
48
+ Stream.unwrap(
49
+ Ref.modify(calls, (cs) => [cs.length, [...cs, after]]).pipe(
50
+ Effect.map((n) => {
51
+ const attempt = attempts[n]
52
+ if (attempt === undefined) {
53
+ return Stream.fromIterable(frames.slice(after)).pipe(Stream.concat(Stream.never))
54
+ }
55
+ const sent = Stream.fromIterable(frames.slice(after, attempt.upTo))
56
+ const error = attempt.error
57
+ return error === undefined ? sent : sent.pipe(Stream.concat(Stream.fail(error)))
58
+ }),
59
+ ),
60
+ )
61
+ const send = () => Effect.succeed(chatId)
62
+ const submit = () => Ref.update(submits, (n) => n + 1).pipe(Effect.as(chatId))
63
+ const session = Transport.session({ send, events })
64
+ const durable = Transport.durable({ submit, events })
65
+ return { calls, submits, session, durable }
66
+ })
67
+
68
+ const fast = Schedule.forever
69
+
70
+ const seqs = (envelopes: ReadonlyArray<Envelope>) => envelopes.map((e) => e.seq)
71
+
72
+ const follow = (session: Transport.Session<unknown, unknown, unknown, never>, take = 5) =>
73
+ session.events(chatId, 0).pipe(Stream.take(take), Stream.runCollect)
74
+
75
+ const failure = (session: Transport.Session<unknown, unknown, unknown, never>) =>
76
+ session.events(chatId, 0).pipe(Stream.runDrain, Effect.flip)
77
+
78
+ describe("Transport.session / durable", () => {
79
+ it.effect("a session stream that completes fails with Disconnected at the last seq", () =>
80
+ Effect.gen(function* () {
81
+ const { session } = yield* server([{ upTo: 2 }])
82
+ expect(yield* failure(session)).toEqual(new Client.Disconnected({ lastSeq: 2 }))
83
+ }),
84
+ )
85
+
86
+ it.effect("a durable run that completes before its terminal fails with Disconnected", () =>
87
+ Effect.gen(function* () {
88
+ const { durable } = yield* server([{ upTo: 3 }])
89
+ const error = yield* durable.start(undefined, undefined).pipe(Stream.runDrain, Effect.flip)
90
+ expect(error).toEqual(new Client.Disconnected({ lastSeq: 3 }))
91
+ }),
92
+ )
93
+ })
94
+
95
+ describe("Transport.reconnect", () => {
96
+ it.effect.each([
97
+ { label: "a clean close", error: undefined },
98
+ { label: "a 503", error: status(503) },
99
+ {
100
+ label: "an SSE retry: line",
101
+ error: new Sse.Retry({ duration: Duration.zero, lastEventId: "2" }),
102
+ },
103
+ ])("re-opens a session from the last seq after $label", ({ error }) =>
104
+ Effect.gen(function* () {
105
+ const { calls, session } = yield* server([
106
+ error === undefined ? { upTo: 2 } : { upTo: 2, error },
107
+ ])
108
+ const received = yield* follow(Transport.reconnect(session, { schedule: fast }))
109
+ expect(seqs(received)).toEqual([1, 2, 3, 4, 5])
110
+ expect(yield* Ref.get(calls)).toEqual([0, 2])
111
+ }),
112
+ )
113
+
114
+ it.effect.each([{ code: 404 }, { code: 401 }])(
115
+ "ends with a $code, without retrying",
116
+ ({ code }) =>
117
+ Effect.gen(function* () {
118
+ const { calls, session } = yield* server([{ upTo: 1, error: status(code) }])
119
+ const error = yield* failure(Transport.reconnect(session, { schedule: fast }))
120
+ expect(error).toEqual(status(code))
121
+ expect(yield* Ref.get(calls)).toEqual([0])
122
+ }),
123
+ )
124
+
125
+ it.effect("`while` adds the app's own transient errors, typed by the transport", () =>
126
+ Effect.gen(function* () {
127
+ const dropped = { upTo: 2, error: new SocketDropped() }
128
+ const plain = yield* server([dropped])
129
+ expect(yield* failure(Transport.reconnect(plain.session, { schedule: fast }))).toEqual(
130
+ new SocketDropped(),
131
+ )
132
+
133
+ const extended = yield* server([dropped])
134
+ const reconnected = Transport.reconnect(extended.session, {
135
+ schedule: fast,
136
+ while: (error) => {
137
+ expectTypeOf(error).toEqualTypeOf<Failure | Client.Disconnected>()
138
+ return error instanceof SocketDropped
139
+ },
140
+ })
141
+ expect(seqs(yield* follow(reconnected))).toEqual([1, 2, 3, 4, 5])
142
+ }),
143
+ )
144
+
145
+ it.effect("pipes, and keeps the transport's kind and errors", () =>
146
+ Effect.gen(function* () {
147
+ const { session, durable } = yield* server([{ upTo: 2, error: status(503) }])
148
+ const piped = Function.pipe(session, Transport.reconnect({ schedule: fast }))
149
+ expectTypeOf(piped).toExtend<
150
+ Transport.Session<unknown, unknown, Failure | Client.Disconnected, never>
151
+ >()
152
+ expectTypeOf(Function.pipe(durable, Transport.reconnect())).toExtend<
153
+ Transport.Durable<unknown, unknown, Failure | Client.Disconnected, never>
154
+ >()
155
+ expect(seqs(yield* follow(piped))).toEqual([1, 2, 3, 4, 5])
156
+ }),
157
+ )
158
+
159
+ it.effect("`times` caps the attempts in a row", () =>
160
+ Effect.gen(function* () {
161
+ const { calls, session } = yield* server(
162
+ Array.from({ length: 5 }, () => ({ upTo: 0, error: status(503) })),
163
+ )
164
+ const error = yield* failure(Transport.reconnect(session, { schedule: fast, times: 2 }))
165
+ expect(error).toEqual(status(503))
166
+ expect((yield* Ref.get(calls)).length).toBe(3)
167
+ }),
168
+ )
169
+
170
+ it.effect("waits at least the server's Retry-After", () =>
171
+ Effect.gen(function* () {
172
+ const { calls, session } = yield* server([
173
+ { upTo: 2, error: status(429, { "retry-after": "5" }) },
174
+ ])
175
+ const fiber = yield* follow(Transport.reconnect(session, { schedule: fast })).pipe(
176
+ Effect.forkChild,
177
+ )
178
+ yield* TestClock.adjust("4 seconds")
179
+ expect(yield* Ref.get(calls)).toEqual([0])
180
+ yield* TestClock.adjust("1 second")
181
+ expect(seqs(yield* Fiber.join(fiber))).toEqual([1, 2, 3, 4, 5])
182
+ expect(yield* Ref.get(calls)).toEqual([0, 2])
183
+ }),
184
+ )
185
+
186
+ it.effect.each([
187
+ { label: "after the first frames", upTo: 2, resumedFrom: 2 },
188
+ { label: "before the first frame", upTo: 0, resumedFrom: 0 },
189
+ ])("durable start submits once and resumes a drop $label", ({ upTo, resumedFrom }) =>
190
+ Effect.gen(function* () {
191
+ const { calls, submits, durable } = yield* server([{ upTo, error: status(502) }])
192
+ const reconnected = Transport.reconnect(durable, { schedule: fast })
193
+ const received = yield* reconnected.start(undefined, undefined).pipe(Stream.runCollect)
194
+ expect(seqs(received)).toEqual([1, 2, 3, 4, 5])
195
+ expect(yield* Ref.get(submits)).toBe(1)
196
+ expect(yield* Ref.get(calls)).toEqual([0, resumedFrom])
197
+ }),
198
+ )
199
+ })