@opencode-ai/client 0.0.0-dev-19178 → 0.0.0-dev-19182

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,4 +1,5 @@
1
1
  export * as OpenCode from "./client.js";
2
+ import { SharedEvents } from "../shared-events.js";
2
3
  import { OpenCode } from "./generated/index.js";
3
4
  import type { ClientOptions } from "./generated/client.js";
4
5
  export type { ClientOptions, RequestOptions } from "./generated/client.js";
@@ -7,9 +8,7 @@ export declare function make(options: ClientOptions): {
7
8
  call: (input: import("./index.js").RpcCallInput, requestOptions?: OpenCode.RequestOptions) => Promise<import("./index.js").RpcOutput>;
8
9
  };
9
10
  event: {
10
- subscribe(options?: {
11
- readonly signal?: AbortSignal;
12
- } | undefined): AsyncIterable<import("./index.js").V2Event>;
11
+ subscribe(options?: SharedEvents.SubscribeOptions): AsyncIterable<import("./index.js").V2Event>;
13
12
  };
14
13
  health: {
15
14
  get: (requestOptions?: OpenCode.RequestOptions) => Promise<import("./index.js").ServiceHealth>;
@@ -4,7 +4,7 @@ import { OpenCode } from "./generated/index.js";
4
4
  import { makeRpc } from "./rpc.js";
5
5
  export function make(options) {
6
6
  const raw = OpenCode.make(options);
7
- const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }));
7
+ const events = SharedEvents.make((signal, onActivity) => raw.event.subscribe({ signal, onActivity }));
8
8
  return {
9
9
  ...raw,
10
10
  rpc: Object.assign(makeRpc(raw, events), raw.rpc),
@@ -7,6 +7,8 @@ export interface ClientOptions {
7
7
  export interface RequestOptions {
8
8
  readonly signal?: AbortSignal;
9
9
  readonly headers?: RequestInit["headers"];
10
+ /** Reports every chunk a streaming response receives, including keepalive comments that yield no event. */
11
+ readonly onActivity?: () => void;
10
12
  }
11
13
  export declare function make(options: ClientOptions): {
12
14
  health: {
@@ -84,6 +84,8 @@ export function make(options) {
84
84
  catch (cause) {
85
85
  throw new ClientError("Transport", { cause });
86
86
  }
87
+ if (!next.done)
88
+ requestOptions?.onActivity?.();
87
89
  buffer += decoder.decode(next.value, { stream: !next.done });
88
90
  if (buffer.length > maxSseEventBytes)
89
91
  throw new ClientError("SseEventTooLarge");
@@ -1,8 +1,11 @@
1
1
  export * as SharedEvents from "./shared-events.js";
2
+ export type SubscribeOptions = {
3
+ readonly signal?: AbortSignal;
4
+ /** Reports transport activity on the shared stream, including keepalive frames that carry no event. */
5
+ readonly onActivity?: () => void;
6
+ };
2
7
  export declare function make<A extends {
3
8
  readonly type: string;
4
- }>(connect: (signal: AbortSignal) => AsyncIterable<A>): {
5
- subscribe(options?: {
6
- readonly signal?: AbortSignal;
7
- }): AsyncIterable<A>;
9
+ }>(connect: (signal: AbortSignal, onActivity: () => void) => AsyncIterable<A>): {
10
+ subscribe(options?: SubscribeOptions): AsyncIterable<A>;
8
11
  };
@@ -14,7 +14,9 @@ export function make(connect) {
14
14
  try {
15
15
  if (connection.controller.signal.aborted)
16
16
  return;
17
- iterator = connect(connection.controller.signal)[Symbol.asyncIterator]();
17
+ iterator = connect(connection.controller.signal, () => {
18
+ connection.subscribers.forEach((subscriber) => subscriber.activity?.());
19
+ })[Symbol.asyncIterator]();
18
20
  while (!connection.controller.signal.aborted) {
19
21
  const item = await iterator.next();
20
22
  if (item.done || connection.controller.signal.aborted)
@@ -66,6 +68,7 @@ export function make(connect) {
66
68
  finish({});
67
69
  }
68
70
  const subscriber = {
71
+ activity: options?.onActivity,
69
72
  finish(result) {
70
73
  finish(result, false);
71
74
  },
@@ -14,16 +14,24 @@ export type ClientConnectionOptions = {
14
14
  readonly onEvent: (event: OpenCodeEvent) => void;
15
15
  readonly flushInterval?: number;
16
16
  readonly pageLifecycle?: boolean;
17
+ /**
18
+ * Abort and reconnect a stream that receives no bytes for this long. The server writes a keepalive
19
+ * comment every 15 seconds, so a quiet but healthy stream never trips this.
20
+ */
21
+ readonly idleTimeout?: number;
17
22
  readonly log?: {
18
23
  readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void;
19
24
  readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void;
20
25
  };
21
26
  };
27
+ export declare const defaultIdleTimeout = 45000;
28
+ export declare const foregroundIdleThreshold = 20000;
22
29
  export declare function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions): {
23
30
  status: () => ClientConnectionStatus;
24
31
  attempt: () => number;
25
32
  error: () => string | undefined;
26
33
  internal: {
27
34
  history: () => ClientConnectionEvent[];
35
+ resync: (reason: string) => void;
28
36
  };
29
37
  };
@@ -1,19 +1,27 @@
1
- import { batch, onCleanup, onMount } from "solid-js";
1
+ import { batch, onCleanup } from "solid-js";
2
2
  import { createStore } from "solid-js/store";
3
3
  const connectTimeout = 2_000;
4
4
  const reconnectDelay = 1_000;
5
5
  const connectionHistoryLimit = 50;
6
+ export const defaultIdleTimeout = 45_000;
7
+ // Longer than one server keepalive interval: a stream that is silent this long when the page
8
+ // returns to the foreground is probably half-open after the device slept.
9
+ export const foregroundIdleThreshold = 20_000;
6
10
  export function createClientConnection(initialApi, options) {
7
11
  const abort = new AbortController();
8
12
  const history = [];
13
+ const idleTimeout = options.idleTimeout ?? defaultIdleTimeout;
9
14
  const [connection, setConnection] = createStore({ status: "connecting", attempt: 0 });
10
15
  let api = initialApi;
11
16
  let pending = [];
12
17
  let flushTimer;
13
18
  let stream;
19
+ let current;
14
20
  let run;
15
21
  let started = false;
16
22
  let generation = 0;
23
+ let lastActivity = 0;
24
+ let forced = false;
17
25
  function record(status, attempt, error) {
18
26
  history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } });
19
27
  if (history.length > connectionHistoryLimit)
@@ -33,13 +41,24 @@ export function createClientConnection(initialApi, options) {
33
41
  async function connect(signal, attempt) {
34
42
  let connectedAt;
35
43
  const request = new AbortController();
44
+ current = request;
36
45
  const cancel = () => request.abort(signal.reason);
37
46
  const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout);
38
47
  signal.addEventListener("abort", cancel, { once: true });
48
+ // Any received bytes, including keepalive comments, push the stall deadline out. A timer whose
49
+ // deadline passed while the page was suspended fires as soon as the page resumes.
50
+ let watchdog;
51
+ const touch = () => {
52
+ lastActivity = Date.now();
53
+ if (connectedAt === undefined)
54
+ return;
55
+ clearTimeout(watchdog);
56
+ watchdog = setTimeout(() => request.abort(new Error("Event stream stalled")), idleTimeout);
57
+ };
39
58
  try {
40
59
  record(attempt === 0 ? "connecting" : "reconnecting", attempt);
41
60
  options.log?.info?.("event stream connecting", { attempt });
42
- const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]();
61
+ const iterator = api.event.subscribe({ signal: request.signal, onActivity: touch })[Symbol.asyncIterator]();
43
62
  const first = await iterator.next();
44
63
  if (signal.aborted)
45
64
  return { error: undefined, connectedAt };
@@ -53,6 +72,7 @@ export function createClientConnection(initialApi, options) {
53
72
  clearTimeout(timeout);
54
73
  record("connected", attempt);
55
74
  connectedAt = Date.now();
75
+ touch();
56
76
  options.log?.info?.("event stream connected");
57
77
  publish(first.value);
58
78
  setConnection({ status: "connected", attempt: 0, error: undefined });
@@ -61,7 +81,11 @@ export function createClientConnection(initialApi, options) {
61
81
  if (signal.aborted)
62
82
  return { error: undefined, connectedAt };
63
83
  if (event.done)
64
- return { error: new Error("Event stream disconnected"), connectedAt };
84
+ return {
85
+ error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
86
+ connectedAt,
87
+ };
88
+ touch();
65
89
  if ("durable" in event.value && event.value.durable)
66
90
  options.log?.debug?.("event", {
67
91
  type: event.value.type,
@@ -77,7 +101,10 @@ export function createClientConnection(initialApi, options) {
77
101
  }
78
102
  finally {
79
103
  request.abort();
104
+ if (current === request)
105
+ current = undefined;
80
106
  clearTimeout(timeout);
107
+ clearTimeout(watchdog);
81
108
  signal.removeEventListener("abort", cancel);
82
109
  }
83
110
  }
@@ -113,6 +140,11 @@ export function createClientConnection(initialApi, options) {
113
140
  continue;
114
141
  }
115
142
  }
143
+ // A deliberate resync already knows the old socket is gone; reconnect without backing off.
144
+ if (forced) {
145
+ forced = false;
146
+ continue;
147
+ }
116
148
  await wait(reconnectDelay, controller.signal);
117
149
  }
118
150
  }
@@ -120,6 +152,7 @@ export function createClientConnection(initialApi, options) {
120
152
  if (started)
121
153
  return run;
122
154
  started = true;
155
+ forced = false;
123
156
  const active = ++generation;
124
157
  const previous = run;
125
158
  const current = (async () => {
@@ -135,26 +168,47 @@ export function createClientConnection(initialApi, options) {
135
168
  return run;
136
169
  }
137
170
  function stop() {
171
+ if (!started)
172
+ return;
138
173
  started = false;
139
174
  generation += 1;
140
175
  stream?.abort();
176
+ // Nothing is listening once stopped, so consumers must treat their data as stale until start() reconnects.
177
+ setConnection({ status: "connecting", attempt: 0, error: undefined });
141
178
  }
142
- onMount(() => {
143
- if (options.pageLifecycle) {
144
- const pagehide = () => stop();
145
- const pageshow = (event) => {
146
- if (event.persisted)
147
- void start();
148
- };
149
- window.addEventListener("pagehide", pagehide);
150
- window.addEventListener("pageshow", pageshow);
151
- onCleanup(() => {
152
- window.removeEventListener("pagehide", pagehide);
153
- window.removeEventListener("pageshow", pageshow);
154
- });
155
- }
156
- void start();
157
- });
179
+ // Drop the live request so the reconnect loop replaces it now instead of waiting for the idle watchdog.
180
+ function resync(reason) {
181
+ if (!started || connection.status !== "connected")
182
+ return;
183
+ options.log?.info?.("event stream resync", { reason, idle: Date.now() - lastActivity });
184
+ forced = true;
185
+ current?.abort(new Error(reason));
186
+ }
187
+ if (options.pageLifecycle) {
188
+ const pagehide = () => stop();
189
+ const pageshow = () => void start();
190
+ // Locking a phone or switching apps hides the document without a pagehide; the socket usually
191
+ // dies while the page is suspended, and the browser may never report that on the hung read.
192
+ const visibility = () => {
193
+ if (document.visibilityState !== "visible")
194
+ return;
195
+ if (Date.now() - lastActivity < foregroundIdleThreshold)
196
+ return;
197
+ resync("Page returned to the foreground after the event stream went quiet");
198
+ };
199
+ const online = () => resync("Network connection restored");
200
+ window.addEventListener("pagehide", pagehide);
201
+ window.addEventListener("pageshow", pageshow);
202
+ window.addEventListener("online", online);
203
+ document.addEventListener("visibilitychange", visibility);
204
+ onCleanup(() => {
205
+ window.removeEventListener("pagehide", pagehide);
206
+ window.removeEventListener("pageshow", pageshow);
207
+ window.removeEventListener("online", online);
208
+ document.removeEventListener("visibilitychange", visibility);
209
+ });
210
+ }
211
+ void start();
158
212
  onCleanup(() => {
159
213
  stop();
160
214
  abort.abort();
@@ -168,6 +222,7 @@ export function createClientConnection(initialApi, options) {
168
222
  error: () => connection.error,
169
223
  internal: {
170
224
  history: () => history.slice(),
225
+ resync,
171
226
  },
172
227
  };
173
228
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@opencode-ai/client",
4
- "version": "0.0.0-dev-19178",
4
+ "version": "0.0.0-dev-19182",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -57,8 +57,8 @@
57
57
  "typecheck": "tsgo --noEmit"
58
58
  },
59
59
  "dependencies": {
60
- "@opencode-ai/schema": "0.0.0-dev-19178",
61
- "@opencode-ai/protocol": "0.0.0-dev-19178"
60
+ "@opencode-ai/schema": "0.0.0-dev-19182",
61
+ "@opencode-ai/protocol": "0.0.0-dev-19182"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "effect": "4.0.0-rc.112",
@@ -74,7 +74,7 @@
74
74
  },
75
75
  "devDependencies": {
76
76
  "@effect/platform-node": "4.0.0-rc.112",
77
- "@opencode-ai/httpapi-codegen": "0.0.0-dev-19178",
77
+ "@opencode-ai/httpapi-codegen": "0.0.0-dev-19182",
78
78
  "@tsconfig/bun": "1.0.9",
79
79
  "@types/bun": "1.4.0",
80
80
  "@typescript/native-preview": "7.0.0-dev.20251207.1",