@moltzap/client 2026.511.1 → 2026.519.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/auth.d.ts +12 -6
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +67 -29
  4. package/dist/auth.js.map +1 -1
  5. package/dist/auth.test.js +113 -76
  6. package/dist/auth.test.js.map +1 -1
  7. package/dist/channel-base/format-cross-conv.d.ts +43 -0
  8. package/dist/channel-base/format-cross-conv.d.ts.map +1 -0
  9. package/dist/channel-base/format-cross-conv.js +69 -0
  10. package/dist/channel-base/format-cross-conv.js.map +1 -0
  11. package/dist/channel-base/format-group-block.d.ts +43 -0
  12. package/dist/channel-base/format-group-block.d.ts.map +1 -0
  13. package/dist/channel-base/format-group-block.js +59 -0
  14. package/dist/channel-base/format-group-block.js.map +1 -0
  15. package/dist/channel-base/index.d.ts +21 -0
  16. package/dist/channel-base/index.d.ts.map +1 -0
  17. package/dist/channel-base/index.js +24 -0
  18. package/dist/channel-base/index.js.map +1 -0
  19. package/dist/channel-base/lease-guard.d.ts +30 -0
  20. package/dist/channel-base/lease-guard.d.ts.map +1 -0
  21. package/dist/channel-base/lease-guard.js +40 -0
  22. package/dist/channel-base/lease-guard.js.map +1 -0
  23. package/dist/channel-base/lease-store.d.ts +30 -0
  24. package/dist/channel-base/lease-store.d.ts.map +1 -0
  25. package/dist/channel-base/lease-store.js +58 -0
  26. package/dist/channel-base/lease-store.js.map +1 -0
  27. package/dist/channel-base/lease.d.ts +94 -0
  28. package/dist/channel-base/lease.d.ts.map +1 -0
  29. package/dist/channel-base/lease.js +110 -0
  30. package/dist/channel-base/lease.js.map +1 -0
  31. package/dist/channel-core-context.test.d.ts +2 -0
  32. package/dist/channel-core-context.test.d.ts.map +1 -0
  33. package/dist/channel-core-context.test.js +334 -0
  34. package/dist/channel-core-context.test.js.map +1 -0
  35. package/dist/channel-core-dispatch.test.d.ts +2 -0
  36. package/dist/channel-core-dispatch.test.d.ts.map +1 -0
  37. package/dist/channel-core-dispatch.test.js +550 -0
  38. package/dist/channel-core-dispatch.test.js.map +1 -0
  39. package/dist/channel-core-enrichment.d.ts +8 -0
  40. package/dist/channel-core-enrichment.d.ts.map +1 -0
  41. package/dist/channel-core-enrichment.js +134 -0
  42. package/dist/channel-core-enrichment.js.map +1 -0
  43. package/dist/channel-core-errors.d.ts +20 -0
  44. package/dist/channel-core-errors.d.ts.map +1 -0
  45. package/dist/channel-core-errors.js +12 -0
  46. package/dist/channel-core-errors.js.map +1 -0
  47. package/dist/channel-core-test-support.d.ts +48 -0
  48. package/dist/channel-core-test-support.d.ts.map +1 -0
  49. package/dist/channel-core-test-support.js +111 -0
  50. package/dist/channel-core-test-support.js.map +1 -0
  51. package/dist/channel-core.d.ts +30 -7
  52. package/dist/channel-core.d.ts.map +1 -1
  53. package/dist/channel-core.js +214 -237
  54. package/dist/channel-core.js.map +1 -1
  55. package/dist/channel-core.test.js +216 -1082
  56. package/dist/channel-core.test.js.map +1 -1
  57. package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
  58. package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
  59. package/dist/cli/commands/conversations-v2.test.js +64 -54
  60. package/dist/cli/commands/conversations-v2.test.js.map +1 -1
  61. package/dist/cli/commands/conversations.d.ts +5 -5
  62. package/dist/cli/commands/conversations.d.ts.map +1 -1
  63. package/dist/cli/commands/conversations.js +43 -36
  64. package/dist/cli/commands/conversations.js.map +1 -1
  65. package/dist/cli/commands/messages.d.ts +4 -4
  66. package/dist/cli/commands/messages.d.ts.map +1 -1
  67. package/dist/cli/commands/messages.js +4 -4
  68. package/dist/cli/commands/messages.js.map +1 -1
  69. package/dist/cli/commands/messages.test.js +61 -49
  70. package/dist/cli/commands/messages.test.js.map +1 -1
  71. package/dist/cli/commands/ping.d.ts +0 -9
  72. package/dist/cli/commands/ping.d.ts.map +1 -1
  73. package/dist/cli/commands/ping.js +1 -1
  74. package/dist/cli/commands/ping.js.map +1 -1
  75. package/dist/cli/commands/presence.d.ts +1 -1
  76. package/dist/cli/commands/presence.js +1 -1
  77. package/dist/cli/commands/register.d.ts +2 -2
  78. package/dist/cli/commands/register.d.ts.map +1 -1
  79. package/dist/cli/commands/register.js +98 -77
  80. package/dist/cli/commands/register.js.map +1 -1
  81. package/dist/cli/commands/register.test.js +63 -39
  82. package/dist/cli/commands/register.test.js.map +1 -1
  83. package/dist/cli/commands/send.d.ts +6 -6
  84. package/dist/cli/commands/send.js +6 -6
  85. package/dist/cli/commands/send.test.js +32 -28
  86. package/dist/cli/commands/send.test.js.map +1 -1
  87. package/dist/cli/commands/status.d.ts.map +1 -1
  88. package/dist/cli/commands/status.js +3 -4
  89. package/dist/cli/commands/status.js.map +1 -1
  90. package/dist/cli/config.d.ts +0 -2
  91. package/dist/cli/config.d.ts.map +1 -1
  92. package/dist/cli/config.js +86 -76
  93. package/dist/cli/config.js.map +1 -1
  94. package/dist/cli/config.test.js +80 -42
  95. package/dist/cli/config.test.js.map +1 -1
  96. package/dist/cli/index.d.ts +1 -16
  97. package/dist/cli/index.d.ts.map +1 -1
  98. package/dist/cli/index.js +50 -113
  99. package/dist/cli/index.js.map +1 -1
  100. package/dist/cli/process-argv.d.ts +2 -0
  101. package/dist/cli/process-argv.d.ts.map +1 -0
  102. package/dist/cli/process-argv.js +2 -0
  103. package/dist/cli/process-argv.js.map +1 -0
  104. package/dist/cli/profile.d.ts +3 -10
  105. package/dist/cli/profile.d.ts.map +1 -1
  106. package/dist/cli/profile.js +105 -104
  107. package/dist/cli/profile.js.map +1 -1
  108. package/dist/cli/profile.test.js +338 -232
  109. package/dist/cli/profile.test.js.map +1 -1
  110. package/dist/cli/runtime.d.ts +5 -17
  111. package/dist/cli/runtime.d.ts.map +1 -1
  112. package/dist/cli/runtime.js +6 -59
  113. package/dist/cli/runtime.js.map +1 -1
  114. package/dist/cli/socket-client.d.ts +10 -15
  115. package/dist/cli/socket-client.d.ts.map +1 -1
  116. package/dist/cli/socket-client.js +74 -89
  117. package/dist/cli/socket-client.js.map +1 -1
  118. package/dist/cli/transport.d.ts +9 -5
  119. package/dist/cli/transport.d.ts.map +1 -1
  120. package/dist/cli/transport.js +45 -42
  121. package/dist/cli/transport.js.map +1 -1
  122. package/dist/cli/transport.test.js +227 -133
  123. package/dist/cli/transport.test.js.map +1 -1
  124. package/dist/index.d.ts +4 -1
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +3 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/local-daemon-rpc.d.ts +13 -0
  129. package/dist/local-daemon-rpc.d.ts.map +1 -0
  130. package/dist/local-daemon-rpc.js +17 -0
  131. package/dist/local-daemon-rpc.js.map +1 -0
  132. package/dist/local-paths.d.ts +7 -0
  133. package/dist/local-paths.d.ts.map +1 -0
  134. package/dist/local-paths.js +20 -0
  135. package/dist/local-paths.js.map +1 -0
  136. package/dist/runtime/close-info.d.ts +3 -12
  137. package/dist/runtime/close-info.d.ts.map +1 -1
  138. package/dist/runtime/close-info.js +28 -39
  139. package/dist/runtime/close-info.js.map +1 -1
  140. package/dist/runtime/close-info.test.js +77 -70
  141. package/dist/runtime/close-info.test.js.map +1 -1
  142. package/dist/runtime/errors.test.js +42 -28
  143. package/dist/runtime/errors.test.js.map +1 -1
  144. package/dist/runtime/frame.d.ts +4 -31
  145. package/dist/runtime/frame.d.ts.map +1 -1
  146. package/dist/runtime/frame.js +7 -116
  147. package/dist/runtime/frame.js.map +1 -1
  148. package/dist/runtime/frame.test.js +41 -54
  149. package/dist/runtime/frame.test.js.map +1 -1
  150. package/dist/runtime/index.d.ts +7 -0
  151. package/dist/runtime/index.d.ts.map +1 -1
  152. package/dist/runtime/index.js +7 -11
  153. package/dist/runtime/index.js.map +1 -1
  154. package/dist/runtime/local-history.d.ts +67 -0
  155. package/dist/runtime/local-history.d.ts.map +1 -0
  156. package/dist/runtime/local-history.js +77 -0
  157. package/dist/runtime/local-history.js.map +1 -0
  158. package/dist/runtime/local-service-commands.d.ts +19 -0
  159. package/dist/runtime/local-service-commands.d.ts.map +1 -1
  160. package/dist/runtime/local-service-commands.js +23 -0
  161. package/dist/runtime/local-service-commands.js.map +1 -1
  162. package/dist/runtime/local-socket-server.d.ts +20 -0
  163. package/dist/runtime/local-socket-server.d.ts.map +1 -0
  164. package/dist/runtime/local-socket-server.js +94 -0
  165. package/dist/runtime/local-socket-server.js.map +1 -0
  166. package/dist/runtime/refs.d.ts +1 -1
  167. package/dist/runtime/refs.js +1 -1
  168. package/dist/runtime/runtime.test.js +29 -21
  169. package/dist/runtime/runtime.test.js.map +1 -1
  170. package/dist/runtime/service-helpers.d.ts +32 -0
  171. package/dist/runtime/service-helpers.d.ts.map +1 -0
  172. package/dist/runtime/service-helpers.js +85 -0
  173. package/dist/runtime/service-helpers.js.map +1 -0
  174. package/dist/runtime/subscribers.d.ts +13 -17
  175. package/dist/runtime/subscribers.d.ts.map +1 -1
  176. package/dist/runtime/subscribers.js +37 -32
  177. package/dist/runtime/subscribers.js.map +1 -1
  178. package/dist/runtime/subscribers.test.js +139 -131
  179. package/dist/runtime/subscribers.test.js.map +1 -1
  180. package/dist/service-socket-path.test.d.ts +2 -0
  181. package/dist/service-socket-path.test.d.ts.map +1 -0
  182. package/dist/service-socket-path.test.js +87 -0
  183. package/dist/service-socket-path.test.js.map +1 -0
  184. package/dist/service.d.ts +23 -20
  185. package/dist/service.d.ts.map +1 -1
  186. package/dist/service.js +192 -340
  187. package/dist/service.js.map +1 -1
  188. package/dist/service.test.js +652 -653
  189. package/dist/service.test.js.map +1 -1
  190. package/dist/test/index.d.ts +30 -14
  191. package/dist/test/index.d.ts.map +1 -1
  192. package/dist/test/index.js +25 -21
  193. package/dist/test/index.js.map +1 -1
  194. package/dist/test-utils/channel-service-fixture.d.ts +1 -0
  195. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  196. package/dist/test-utils/channel-service-fixture.js +139 -98
  197. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  198. package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
  199. package/dist/test-utils/conformance-adapter.js +145 -124
  200. package/dist/test-utils/conformance-adapter.js.map +1 -1
  201. package/dist/test-utils/fake-service.d.ts.map +1 -1
  202. package/dist/test-utils/fake-service.js +3 -3
  203. package/dist/test-utils/fake-service.js.map +1 -1
  204. package/dist/test-utils/index.d.ts +25 -0
  205. package/dist/test-utils/index.d.ts.map +1 -1
  206. package/dist/test-utils/index.js +25 -8
  207. package/dist/test-utils/index.js.map +1 -1
  208. package/dist/ws-client-test-support.d.ts +180 -0
  209. package/dist/ws-client-test-support.d.ts.map +1 -0
  210. package/dist/ws-client-test-support.js +399 -0
  211. package/dist/ws-client-test-support.js.map +1 -0
  212. package/dist/ws-client.d.ts +59 -32
  213. package/dist/ws-client.d.ts.map +1 -1
  214. package/dist/ws-client.js +272 -321
  215. package/dist/ws-client.js.map +1 -1
  216. package/dist/ws-client.test.js +388 -800
  217. package/dist/ws-client.test.js.map +1 -1
  218. package/package.json +18 -10
package/dist/ws-client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as Socket from "@effect/platform/Socket";
2
2
  import * as NodeSocket from "@effect/platform-node/NodeSocket";
3
- import { Cause, Data, Deferred, Duration, Either, Effect, Exit, Fiber, HashMap, ManagedRuntime, Option, Queue, Ref, Schedule, Scope, } from "effect";
4
- import { PROTOCOL_VERSION, Connect, encodeErrorResponse, NotConnectedError, RpcServerError, RpcTimeoutError, } from "@moltzap/protocol";
3
+ import { Data, Deferred, Duration, Either, Effect, Exit, Fiber, HashMap, ManagedRuntime, Option, Queue, Ref, Schedule, Scope, } from "effect";
4
+ import { PROTOCOL_VERSION, Connect, encodeErrorResponse, makeJsonRpcClient, makeJsonRpcServer, NotConnectedError, RpcTimeoutError, } from "@moltzap/protocol";
5
5
  import { DuplicateServerRpcHandlerError } from "./runtime/errors.js";
6
6
  import { decodeFrames } from "./runtime/frame.js";
7
7
  import { makeSubscriberRegistry, } from "./runtime/subscribers.js";
@@ -19,12 +19,12 @@ const NORMAL_CLOSE_CODE = 1000;
19
19
  const EVENT_WAIT_TIMEOUT_MS = 5000;
20
20
  const WEB_SOCKET_OPEN_TIMEOUT_SECONDS = 10;
21
21
  const MALFORMED_FRAME_PREVIEW_CHARS = 200;
22
- const JSON_RPC_INTERNAL_ERROR_CODE = -32603;
23
22
  /**
24
23
  * Log 1-of-N malformed frames. A misbehaving server could flood us otherwise;
25
24
  * the counter in the log makes it clear how many we've dropped between logs.
26
25
  */
27
26
  const MALFORMED_LOG_EVERY = 50;
27
+ export const shouldLogMalformedFrame = (count) => count === 1 || count % MALFORMED_LOG_EVERY === 0;
28
28
  /**
29
29
  * Cap on the per-client notification buffer. Any frame that has no live
30
30
  * `waitForNotification` awaiter lands here until someone drains it. Excess
@@ -44,15 +44,16 @@ const MAX_EVENT_BUFFER = 1000;
44
44
  */
45
45
  const TASK_CALLBACK_QUEUE_CAPACITY = 8192;
46
46
  const MSG_NOT_CONNECTED = "WebSocket not connected";
47
- const MSG_RPC_ERROR_FALLBACK = "RPC error";
48
47
  const UTF8_DECODER = new TextDecoder("utf-8");
49
48
  const makeNotConnectedError = () => new NotConnectedError({ message: MSG_NOT_CONNECTED });
50
49
  class ReconnectAttemptFailedError extends Data.TaggedError("ReconnectAttemptFailedError") {
51
50
  }
52
- /** Notifications are pre-validated by the wire decoder
51
+ /**
52
+ * Notifications are pre-validated by the wire decoder
53
53
  * (`decodeNotification` in `@moltzap/protocol`) — fail-close on unknown
54
54
  * methods or bad params. Buffer + waiters hold the validated shape
55
- * directly; no separate Raw|Unknown lift remains. */
55
+ * directly; no separate Raw|Unknown lift remains.
56
+ */
56
57
  function acceptTypedNotification(definition, notification) {
57
58
  return notification.definition === definition;
58
59
  }
@@ -80,7 +81,6 @@ function removeWaiter(m, definition, waiter) {
80
81
  */
81
82
  export class MoltZapWsClient {
82
83
  options;
83
- pendingRef;
84
84
  stateRef;
85
85
  malformedRef;
86
86
  notificationsBufferRef;
@@ -104,14 +104,12 @@ export class MoltZapWsClient {
104
104
  * dispatcher fiber when an appCallback request frame arrives.
105
105
  */
106
106
  appCallbackHandlersRef;
107
- requestCounter = 0;
108
107
  closed = false;
109
108
  reconnectFiber = null;
110
109
  _helloOk = null;
111
110
  constructor(options) {
112
111
  this.options = options;
113
112
  this.runtime = ManagedRuntime.make(NodeSocket.layerWebSocketConstructor);
114
- this.pendingRef = this.runtime.runSync(Ref.make(HashMap.empty()));
115
113
  this.stateRef = this.runtime.runSync(Ref.make(Option.none()));
116
114
  this.malformedRef = this.runtime.runSync(Ref.make(0));
117
115
  this.notificationsBufferRef = this.runtime.runSync(Ref.make([]));
@@ -119,15 +117,13 @@ export class MoltZapWsClient {
119
117
  // Registry construction is `Effect<…, never>`; running it sync here
120
118
  // matches every other Ref initializer in this constructor and
121
119
  // keeps `subscribers` non-nullable inside the class.
122
- this.subscribers = this.runtime.runSync(makeSubscriberRegistry({
123
- warn: (...args) => this.options.logger?.warn(...args),
124
- }));
120
+ this.subscribers = this.runtime.runSync(makeSubscriberRegistry());
125
121
  this.appCallbackHandlersRef = this.runtime.runSync(Ref.make(HashMap.empty()));
126
122
  }
127
123
  /**
128
124
  * Register a handler for a server-initiated RPC method. Survives
129
125
  * reconnects — the registry lives on the client, not the per-connection
130
- * `ConnState`. Returns `Effect<void>` that fails with
126
+ * `ConnState`. Returns `Effect&lt;void>` that fails with
131
127
  * `DuplicateServerRpcHandlerError` if a handler for `method` is already
132
128
  * registered (shadowing the existing one would silently swap behaviour
133
129
  * mid-flight).
@@ -155,8 +151,10 @@ export class MoltZapWsClient {
155
151
  get helloOk() {
156
152
  return this._helloOk;
157
153
  }
158
- /** Open the socket, perform network/connect, resolve with HelloOk. Fails
159
- * immediately on pre-open close or error. */
154
+ /**
155
+ * Open the socket, perform network/connect, resolve with HelloOk. Fails
156
+ * immediately on pre-open close or error.
157
+ */
160
158
  connect() {
161
159
  return Effect.suspend(() => {
162
160
  if (this.closed) {
@@ -173,19 +171,15 @@ export class MoltZapWsClient {
173
171
  * Send an RPC. Fails with a typed error:
174
172
  * - `NotConnectedError` if the socket isn't OPEN or closes mid-RPC
175
173
  * - `RpcTimeoutError` after `RPC_TIMEOUT_MS` — no automatic retry
176
- * - `RpcServerError` on a typed server-error frame
174
+ * - a registered tagged error for known protocol error codes
175
+ * - `RpcServerError` for unknown protocol error codes
177
176
  *
178
177
  * Descriptor-backed RPC call. Callers pass the protocol descriptor, and the
179
178
  * client extracts the wire method only inside the encoder path.
180
179
  */
181
180
  sendRpc(definition, params, opts) {
182
- return this.sendRpcEffect(definition, params, opts).pipe(Effect.flatMap((result) => definition.validateResult(result)
183
- ? Effect.succeed(result)
184
- : Effect.fail(new RpcServerError({
185
- code: JSON_RPC_INTERNAL_ERROR_CODE,
186
- message: `Invalid result for method: ${definition.name}`,
187
- data: result,
188
- }))));
181
+ const timeoutMs = opts?.timeoutMs ?? RPC_TIMEOUT_MS;
182
+ return this.sendRpcEffect(definition, params, timeoutMs);
189
183
  }
190
184
  /**
191
185
  * Register a per-subscription notification handler. Spec #222 §5.3 + OQ-2
@@ -250,49 +244,70 @@ export class MoltZapWsClient {
250
244
  this.runtime.runFork(Scope.close(state.value.dispatcherScope, Exit.void));
251
245
  }
252
246
  }).pipe(Effect.asVoid, Effect.ensuring(Effect.sync(() => {
253
- void this.runtime.dispose();
247
+ this.runtime.dispose();
254
248
  })));
255
249
  }
256
- /** Wait for the next inbound notification matching `definition`.
250
+ /**
251
+ * Wait for the next inbound notification matching `definition`.
257
252
  * Consumes a buffered match if present; otherwise awaits the next match
258
- * with a per-call timeout. */
253
+ * with a per-call timeout.
254
+ */
259
255
  waitForNotification(definition, timeoutMs = EVENT_WAIT_TIMEOUT_MS) {
260
256
  return Effect.gen(this, function* () {
261
- const buffered = yield* Ref.modify(this.notificationsBufferRef, (frames) => {
262
- for (const [idx, frame] of frames.entries()) {
263
- if (!acceptTypedNotification(definition, frame))
264
- continue;
265
- const next = [...frames.slice(0, idx), ...frames.slice(idx + 1)];
266
- return [frame, next];
267
- }
268
- return [null, frames];
269
- });
257
+ const buffered = yield* this.takeBufferedNotification(definition);
270
258
  if (buffered !== null)
271
259
  return buffered;
260
+ return yield* this.awaitNotification(definition, timeoutMs);
261
+ });
262
+ }
263
+ takeBufferedNotification(definition) {
264
+ return Ref.modify(this.notificationsBufferRef, (frames) => {
265
+ for (const [idx, frame] of frames.entries()) {
266
+ if (!acceptTypedNotification(definition, frame))
267
+ continue;
268
+ const next = [...frames.slice(0, idx), ...frames.slice(idx + 1)];
269
+ return [frame, next];
270
+ }
271
+ return [null, frames];
272
+ });
273
+ }
274
+ awaitNotification(definition, timeoutMs) {
275
+ return Effect.gen(this, function* () {
272
276
  const deferred = yield* Deferred.make();
273
- const waiter = {
274
- definition,
275
- complete: (notification) => acceptTypedNotification(definition, notification)
276
- ? Deferred.succeed(deferred, notification).pipe(Effect.asVoid)
277
- : Effect.void,
278
- fail: (error) => Deferred.fail(deferred, error).pipe(Effect.asVoid),
279
- };
280
- yield* Ref.update(this.notificationWaitersRef, (m) => {
281
- const existing = HashMap.get(m, definition);
282
- const next = existing._tag === "Some" ? [...existing.value, waiter] : [waiter];
283
- return HashMap.set(m, definition, next);
284
- });
277
+ const waiter = this.makeNotificationWaiter(definition, deferred);
278
+ yield* this.addNotificationWaiter(definition, waiter);
285
279
  return yield* Deferred.await(deferred).pipe(Effect.timeoutFail({
286
280
  duration: `${timeoutMs} millis`,
287
281
  onTimeout: () => new Error(`Timeout waiting for notification: ${definition.name}`),
288
- }), Effect.onExit((exit) => exit._tag === "Failure"
289
- ? Ref.update(this.notificationWaitersRef, (m) => removeWaiter(m, definition, waiter))
282
+ }), Effect.onExit((exit) => Exit.isFailure(exit)
283
+ ? this.removeNotificationWaiter(definition, waiter)
290
284
  : Effect.void));
291
285
  });
292
286
  }
293
- /** Return all buffered notifications and clear the buffer. Synchronous.
287
+ makeNotificationWaiter(definition, deferred) {
288
+ return {
289
+ definition,
290
+ complete: (notification) => acceptTypedNotification(definition, notification)
291
+ ? Deferred.succeed(deferred, notification).pipe(Effect.asVoid)
292
+ : Effect.void,
293
+ fail: (error) => Deferred.fail(deferred, error).pipe(Effect.asVoid),
294
+ };
295
+ }
296
+ addNotificationWaiter(definition, waiter) {
297
+ return Ref.update(this.notificationWaitersRef, (m) => {
298
+ const existing = HashMap.get(m, definition);
299
+ const next = existing._tag === "Some" ? [...existing.value, waiter] : [waiter];
300
+ return HashMap.set(m, definition, next);
301
+ });
302
+ }
303
+ removeNotificationWaiter(definition, waiter) {
304
+ return Ref.update(this.notificationWaitersRef, (m) => removeWaiter(m, definition, waiter));
305
+ }
306
+ /**
307
+ * Return all buffered notifications and clear the buffer. Synchronous.
294
308
  * Frames are pre-validation (`params: unknown`) — buffer holds the
295
- * raw shape produced by the wire decoder. */
309
+ * raw shape produced by the wire decoder.
310
+ */
296
311
  drainNotifications() {
297
312
  const snapshot = this.runtime.runSync(Ref.get(this.notificationsBufferRef));
298
313
  this.runtime.runSync(Ref.set(this.notificationsBufferRef, []));
@@ -308,11 +323,7 @@ export class MoltZapWsClient {
308
323
  return;
309
324
  // Detach from state first so sendRpc fails fast while we tear down.
310
325
  this.runtime.runSync(Ref.set(this.stateRef, Option.none()));
311
- // Fail pendings via runFork — `failAllPending` yields through
312
- // `Deferred.fail` (not safe for runSync). Fire-and-forget: the reader
313
- // fiber's onExit will also drain on interrupt; duplicate drain is
314
- // harmless because `failAllPending` resets the pendingRef atomically.
315
- this.runtime.runFork(this.failAllPending(MSG_NOT_CONNECTED));
326
+ this.runtime.runFork(this.failConnectionPending(state.value));
316
327
  // Interrupt the reader fiber. runRaw exits, the socket scope closes,
317
328
  // ws.close(1000) fires as part of that teardown.
318
329
  this.runtime.runFork(Fiber.interrupt(state.value.readerFiber));
@@ -325,166 +336,105 @@ export class MoltZapWsClient {
325
336
  // finalizers.
326
337
  this.runtime.runFork(Scope.close(state.value.dispatcherScope, Exit.void));
327
338
  }
339
+ notifyDisconnect(close) {
340
+ return Effect.gen(this, function* () {
341
+ try {
342
+ this.options.onDisconnect?.(close);
343
+ }
344
+ catch (err) {
345
+ yield* Effect.logWarning("onDisconnect handler threw", err);
346
+ }
347
+ });
348
+ }
328
349
  connectEffect() {
329
350
  return Effect.gen(this, function* () {
330
- const url = this.options.serverUrl.replace(/^http/, "ws") + "/ws";
331
- // Fresh scope per connect attempt. Held by the client (not the caller's
332
- // fiber) so the reader + writer outlive the outer `connect()` Effect.
351
+ const url = this.webSocketUrl();
333
352
  const scope = yield* Scope.make();
334
- // Map Socket open failures (SocketGenericError / SocketCloseError) to
335
- // NotConnectedError so callers see a single typed error.
336
- const openTimeout = Duration.seconds(WEB_SOCKET_OPEN_TIMEOUT_SECONDS);
337
- const socket = yield* Scope.extend(Socket.makeWebSocket(url, { openTimeout }), scope).pipe(Effect.timeoutFail({
338
- duration: openTimeout,
339
- onTimeout: makeNotConnectedError,
340
- }), Effect.catchAllCause((cause) => Effect.zipRight(Effect.sync(() => this.options.logger?.warn("WebSocket open failed", cause)), Effect.sync(() => {
341
- this.runtime.runFork(Scope.close(scope, Exit.void));
342
- }).pipe(Effect.zipRight(Effect.fail(makeNotConnectedError()))))));
353
+ const socket = yield* this.openSocket(url, scope);
343
354
  const write = yield* Scope.extend(socket.writer, scope);
344
- // Settled first by whichever fires: the network/connect response, or
345
- // reader-fiber exit on any close/error before handshake.
355
+ const jsonRpcClient = yield* Scope.extend(makeJsonRpcClient({
356
+ write: (raw) => write(raw),
357
+ idPrefix: "rpc",
358
+ }), scope);
346
359
  const handshakeSettled = yield* Deferred.make();
347
- // Cutover (#533) — single bounded global queue + single drain
348
- // fiber replaces the pre-cutover partitioned dispatcher. The
349
- // queue capacity (`TASK_CALLBACK_QUEUE_CAPACITY` = 8192)
350
- // preserves today's 256×32 burst envelope. The drain fiber
351
- // takes one request at a time and runs the registered handler
352
- // serially; the only remaining task-callback descriptor today
353
- // is `dispatch/authorize`, which the recipient may not handle
354
- // concurrently with itself.
355
- //
356
- // Dispatcher Scope is allocated independently of the per-connect
357
- // socket Scope (`scope` above). Closing it interrupts the drain
358
- // fiber via Scope finalizers; teardown from `close()` /
359
- // `disconnectSync()` is `runFork(Scope.close(...))`, mirroring
360
- // the reader-fiber ownership pattern (off-Scope so
361
- // `runSync(client.close())` doesn't yield through the runtime
362
- // — load-bearing regression gate at
363
- // `ws-client.test.ts:1233-1259`).
364
- const dispatcherScope = yield* Scope.make();
365
- const taskCallbackQueue = yield* Queue.bounded(TASK_CALLBACK_QUEUE_CAPACITY);
366
- // Drain fiber: serialize handler execution. Forked into the
367
- // off-Scope `dispatcherScope` so it is interrupted exactly when
368
- // the dispatcher Scope closes, not when the socket Scope closes.
369
- const drainEffect = Effect.forever(Queue.take(taskCallbackQueue).pipe(Effect.flatMap((req) => this.dispatchInboundServerRequest(req, write))));
370
- yield* Effect.forkIn(drainEffect, dispatcherScope);
371
- // Use `onExit` (not `tapErrorCause`) so the clean-close path also
372
- // triggers pending-drain. `@effect/platform/Socket` treats code 1000
373
- // as a SUCCESS exit, so error-only handlers miss it and pending RPCs
374
- // would hang forever.
375
- const readerEffect = socket
376
- .runRaw((data) => this.handleIncoming(typeof data === "string" ? data : UTF8_DECODER.decode(data)))
377
- .pipe(Effect.onExit((exit) => Effect.gen(this, function* () {
378
- if (Exit.isFailure(exit)) {
379
- this.options.logger?.warn("WebSocket error", exit.cause);
380
- }
381
- this._helloOk = null;
382
- yield* this.failAllPending(MSG_NOT_CONNECTED);
383
- // Unblock any `connect()` still awaiting the handshake.
384
- yield* Deferred.fail(handshakeSettled, makeNotConnectedError()).pipe(Effect.ignore);
385
- // Clear connection state BEFORE the appCallback teardown so
386
- // `sendRpc` and observers see the closed state immediately.
387
- // Awaiting `Fiber.interrupt` would block this branch on a
388
- // slow appCallback handler still draining (codex P2).
389
- yield* Ref.set(this.stateRef, Option.none());
390
- // Tear down the task-callback dispatcher Scope on
391
- // socket-level close (e.g. server-initiated). `close()`
392
- // / `disconnectSync()` already handle their own
393
- // teardown for client-initiated paths; this branch
394
- // covers the case where the server closes us. Forked
395
- // so a slow handler does not delay the rest of the
396
- // disconnect path. Idempotent with the explicit
397
- // teardown in close().
398
- this.runtime.runFork(Scope.close(dispatcherScope, Exit.void));
399
- // Spec #222 §5.4 (V7): project the reader-fiber exit onto
400
- // a typed `CloseInfo` and pass it to `onDisconnect`. Pure
401
- // total classifier — see runtime/close-info.ts.
402
- const close = extractCloseInfo(exit);
403
- yield* Effect.sync(() => {
404
- try {
405
- this.options.onDisconnect?.(close);
406
- }
407
- catch (err) {
408
- this.options.logger?.warn("onDisconnect handler threw", err);
409
- }
410
- });
411
- if (!this.closed) {
412
- this.scheduleReconnect();
413
- }
414
- })));
415
- // Fork the reader on the CLIENT's runtime (not the caller's fiber) so
416
- // it outlives the outer `connect()`. Otherwise the caller's fiber tree
417
- // finalizes on return, interrupts the reader, and `onExit` clears
418
- // `_helloOk` behind a caller that believed connect() succeeded.
419
- const readerFiber = this.runtime.runFork(readerEffect);
420
- // Publish state BEFORE network/connect: the write goes through
421
- // `sendRpcEffect`, which reads `stateRef`.
422
- yield* Ref.set(this.stateRef, Option.some({
360
+ const dispatcher = yield* this.startTaskCallbackDispatcher(write);
361
+ const readerFiber = this.runtime.runFork(this.readerEffect(socket, handshakeSettled, dispatcher.dispatcherScope));
362
+ yield* this.publishConnectionState({
423
363
  write,
424
364
  readerFiber,
425
365
  scope,
366
+ jsonRpcClient,
426
367
  handshakeSettled,
427
- taskCallbackQueue,
428
- dispatcherScope,
429
- }));
430
- const authEffect = this.sendRpc(Connect, {
431
- agentKey: this.options.agentKey,
432
- minProtocol: PROTOCOL_VERSION,
433
- maxProtocol: PROTOCOL_VERSION,
368
+ taskCallbackQueue: dispatcher.taskCallbackQueue,
369
+ dispatcherScope: dispatcher.dispatcherScope,
434
370
  });
435
- // Race the network/connect response against the handshakeSettled deferred
436
- // `raceFirst` (not `race`) — `race` waits for the loser when the
437
- // winner fails, so a typed network/connect error would hang behind the
438
- // still-pending handshake-watchdog Deferred.
439
- const result = yield* Effect.raceFirst(authEffect, Deferred.await(handshakeSettled)).pipe(Effect.tap((value) => Effect.sync(() => {
440
- this._helloOk = value;
441
- })));
442
- return result;
371
+ return yield* this.awaitConnectAuth(handshakeSettled);
372
+ });
373
+ }
374
+ webSocketUrl() {
375
+ return this.options.serverUrl.replace(/^http/, "ws") + "/ws";
376
+ }
377
+ openSocket(url, scope) {
378
+ const openTimeout = Duration.seconds(WEB_SOCKET_OPEN_TIMEOUT_SECONDS);
379
+ return Scope.extend(Socket.makeWebSocket(url, { openTimeout }), scope).pipe(Effect.timeoutFail({
380
+ duration: openTimeout,
381
+ onTimeout: makeNotConnectedError,
382
+ }), Effect.catchAllCause((cause) => Effect.zipRight(Effect.logWarning("WebSocket open failed", cause), Effect.sync(() => {
383
+ this.runtime.runFork(Scope.close(scope, Exit.void));
384
+ }).pipe(Effect.zipRight(Effect.fail(makeNotConnectedError()))))));
385
+ }
386
+ startTaskCallbackDispatcher(write) {
387
+ return Effect.gen(this, function* () {
388
+ const dispatcherScope = yield* Scope.make();
389
+ const taskCallbackQueue = yield* Queue.bounded(TASK_CALLBACK_QUEUE_CAPACITY);
390
+ const drainEffect = Effect.forever(Queue.take(taskCallbackQueue).pipe(Effect.flatMap((req) => this.dispatchInboundServerRequest(req, write))));
391
+ yield* Effect.forkIn(drainEffect, dispatcherScope);
392
+ return { dispatcherScope, taskCallbackQueue };
393
+ });
394
+ }
395
+ readerEffect(socket, handshakeSettled, dispatcherScope) {
396
+ return socket
397
+ .runRaw((data) => this.handleIncoming(typeof data === "string" ? data : UTF8_DECODER.decode(data)))
398
+ .pipe(Effect.onExit((exit) => this.handleReaderExit(exit, handshakeSettled, dispatcherScope)));
399
+ }
400
+ handleReaderExit(exit, handshakeSettled, dispatcherScope) {
401
+ return Effect.gen(this, function* () {
402
+ if (Exit.isFailure(exit)) {
403
+ yield* Effect.logWarning("WebSocket error", exit.cause);
404
+ }
405
+ this._helloOk = null;
406
+ yield* this.failAllPending(MSG_NOT_CONNECTED);
407
+ yield* Deferred.fail(handshakeSettled, makeNotConnectedError()).pipe(Effect.ignore);
408
+ yield* Ref.set(this.stateRef, Option.none());
409
+ this.runtime.runFork(Scope.close(dispatcherScope, Exit.void));
410
+ yield* this.notifyDisconnect(extractCloseInfo(exit));
411
+ if (!this.closed)
412
+ this.scheduleReconnect();
413
+ });
414
+ }
415
+ publishConnectionState(state) {
416
+ return Ref.set(this.stateRef, Option.some(state));
417
+ }
418
+ awaitConnectAuth(handshakeSettled) {
419
+ const authEffect = this.sendRpc(Connect, {
420
+ agentKey: this.options.agentKey,
421
+ minProtocol: PROTOCOL_VERSION,
422
+ maxProtocol: PROTOCOL_VERSION,
443
423
  });
424
+ return Effect.raceFirst(authEffect, Deferred.await(handshakeSettled)).pipe(Effect.tap((value) => Effect.sync(() => {
425
+ this._helloOk = value;
426
+ })));
444
427
  }
445
- sendRpcEffect(definition, params, opts) {
428
+ sendRpcEffect(definition, params, timeoutMs) {
446
429
  return Effect.gen(this, function* () {
447
- const method = definition.name;
448
430
  const state = yield* Ref.get(this.stateRef);
449
431
  if (Option.isNone(state)) {
450
432
  return yield* Effect.fail(makeNotConnectedError());
451
433
  }
452
- const frame = definition.encodeRequest(`rpc-${++this.requestCounter}`, params);
453
- const id = frame.id;
454
- // Register the Deferred BEFORE writing — write yields, reader could
455
- // close + failAllPending before we register, leaving us awaiting a
456
- // never-resolved Deferred.
457
- const deferred = yield* Deferred.make();
458
- yield* Ref.update(this.pendingRef, (m) => HashMap.set(m, id, deferred));
459
- const writeAttempt = Effect.either(state.value.write(JSON.stringify(frame)));
460
- const earlyFailure = Deferred.await(deferred).pipe(Effect.catchAll(() => Effect.succeed(null)), Effect.as(null));
461
- const writeRace = yield* Effect.race(writeAttempt, earlyFailure);
462
- const writeFailure = writeRace === null
463
- ? null
464
- : Either.match(writeRace, {
465
- onLeft: (err) => err,
466
- onRight: () => null,
467
- });
468
- if (writeFailure !== null) {
469
- this.options.logger?.warn("ws.send failed", writeFailure);
470
- yield* Ref.update(this.pendingRef, (m) => HashMap.remove(m, id));
471
- return yield* Effect.fail(makeNotConnectedError());
472
- }
473
- const timeoutMs = opts?.timeoutMs ?? RPC_TIMEOUT_MS;
474
- const result = yield* Deferred.await(deferred).pipe(Effect.timeoutFail({
434
+ return yield* state.value.jsonRpcClient.call(definition, params).pipe(Effect.timeoutFail({
475
435
  duration: `${timeoutMs} millis`,
476
- onTimeout: () => new RpcTimeoutError({ method, timeoutMs }),
477
- }), Effect.onExit((exit) => Exit.isFailure(exit)
478
- ? Ref.update(this.pendingRef, (m) => HashMap.remove(m, id))
479
- : Effect.void));
480
- if (!definition.validateResult(result)) {
481
- return yield* Effect.fail(new RpcServerError({
482
- code: JSON_RPC_INTERNAL_ERROR_CODE,
483
- message: `Invalid result for method: ${definition.name}`,
484
- data: result,
485
- }));
486
- }
487
- return result;
436
+ onTimeout: () => new RpcTimeoutError({ method: definition.name, timeoutMs }),
437
+ }));
488
438
  });
489
439
  }
490
440
  /**
@@ -498,7 +448,7 @@ export class MoltZapWsClient {
498
448
  code: -32000,
499
449
  message: `Server busy: task-callback executor queue full (capacity=${TASK_CALLBACK_QUEUE_CAPACITY})`,
500
450
  });
501
- return write(JSON.stringify(reply)).pipe(Effect.catchAll((werr) => Effect.sync(() => this.options.logger?.warn("task-callback queue-full rejection write failed", werr))));
451
+ return write(JSON.stringify(reply)).pipe(Effect.catchAll((werr) => Effect.logWarning("task-callback queue-full rejection write failed", werr)));
502
452
  }
503
453
  /**
504
454
  * Dispatch one inbound appCallback request to the registered handler, encode
@@ -509,130 +459,125 @@ export class MoltZapWsClient {
509
459
  *
510
460
  * Cases:
511
461
  * - Handler registered + Effect succeeds → encode `result`.
512
- * - Handler registered + Effect fails (RpcServerError) → encode
513
- * `error` from the tag.
462
+ * - Handler registered + Effect fails with a registered tagged error →
463
+ * encode `error` from the tag.
514
464
  * - Handler registered + Effect defects (untagged crash) →
515
465
  * encode generic InternalError, log the cause.
516
466
  * - No handler registered → encode MethodNotFound error response.
517
467
  */
518
468
  dispatchInboundServerRequest(request, write) {
469
+ return Effect.gen(this, function* () {
470
+ const reply = yield* this.buildInboundServerReply(request);
471
+ yield* this.writeInboundServerReply(write, reply);
472
+ });
473
+ }
474
+ buildInboundServerReply(request) {
519
475
  return Effect.gen(this, function* () {
520
476
  const handlers = yield* Ref.get(this.appCallbackHandlersRef);
521
- const lookup = HashMap.get(handlers, request.definition);
522
- const buildReply = lookup._tag === "None"
523
- ? Effect.succeed(encodeErrorResponse(request.id, {
524
- code: -32601,
525
- message: "No handler registered for app callback descriptor",
526
- }))
527
- : lookup
528
- .value(request.params, {
529
- requestId: request.id,
530
- definition: request.definition,
531
- })
532
- .pipe(Effect.match({
533
- onSuccess: (result) => request.definition.encodeResponse(request.id, result),
534
- onFailure: (err) => encodeErrorResponse(request.id, {
535
- code: err.code,
536
- message: err.message,
537
- ...(err.data !== undefined ? { data: err.data } : {}),
538
- }),
539
- }), Effect.catchAllCause((cause) => Effect.sync(() => {
540
- this.options.logger?.warn("appCallback handler defected", Cause.pretty(cause));
541
- return encodeErrorResponse(request.id, {
542
- code: -32603,
543
- message: "Internal error",
544
- });
545
- })));
546
- const reply = yield* buildReply;
547
- yield* write(JSON.stringify(reply)).pipe(Effect.catchAll((err) => Effect.sync(() => this.options.logger?.warn("appCallback response write failed", err))));
477
+ const rpcServer = makeJsonRpcServer(this.appCallbackRpcHandlers(handlers));
478
+ return yield* rpcServer.handle(request.frame, {
479
+ requestId: request.id,
480
+ definition: request.definition,
481
+ });
548
482
  });
549
483
  }
550
- /** Route an inbound frame. Malformed frames are logged + dropped; notification
551
- * frames dispatch to `onNotification` after the shape check. */
484
+ appCallbackRpcHandlers(handlers) {
485
+ return Array.from(HashMap.entries(handlers), ([definition, appHandler]) => ({
486
+ definition,
487
+ handle: (params, ctx) => appHandler(params, { ...ctx, definition }),
488
+ }));
489
+ }
490
+ writeInboundServerReply(write, reply) {
491
+ return write(JSON.stringify(reply)).pipe(Effect.catchAll((err) => Effect.logWarning("appCallback response write failed", err)));
492
+ }
493
+ recordMalformedFrame(err) {
494
+ return Effect.gen(this, function* () {
495
+ const count = yield* Ref.updateAndGet(this.malformedRef, (n) => n + 1);
496
+ if (shouldLogMalformedFrame(count)) {
497
+ yield* Effect.logWarning(`Malformed frame (#${count})`).pipe(Effect.annotateLogs({
498
+ rawPreview: err.raw.slice(0, MALFORMED_FRAME_PREVIEW_CHARS),
499
+ }));
500
+ }
501
+ return null;
502
+ });
503
+ }
504
+ handleDecodedResponse(decoded) {
505
+ return Effect.gen(this, function* () {
506
+ const state = yield* Ref.get(this.stateRef);
507
+ if (Option.isNone(state))
508
+ return;
509
+ yield* state.value.jsonRpcClient
510
+ .resolve(decoded.frame)
511
+ .pipe(Effect.asVoid);
512
+ });
513
+ }
514
+ handleDecodedServerRequest(decoded) {
515
+ return Effect.gen(this, function* () {
516
+ const state = yield* Ref.get(this.stateRef);
517
+ if (Option.isNone(state))
518
+ return;
519
+ const offered = yield* Queue.offer(state.value.taskCallbackQueue, decoded);
520
+ if (!offered) {
521
+ yield* this.writeQueueFullRejection(decoded.id, state.value.write);
522
+ }
523
+ });
524
+ }
525
+ takeNotificationWaiter(decoded) {
526
+ return Ref.modify(this.notificationWaitersRef, (waitersMap) => {
527
+ const bucket = HashMap.get(waitersMap, decoded.definition);
528
+ if (bucket._tag === "None" || bucket.value.length === 0) {
529
+ return [null, waitersMap];
530
+ }
531
+ const arr = bucket.value;
532
+ const chosen = arr[arr.length - 1];
533
+ const rest = arr.slice(0, -1);
534
+ const nextMap = rest.length === 0
535
+ ? HashMap.remove(waitersMap, decoded.definition)
536
+ : HashMap.set(waitersMap, decoded.definition, rest);
537
+ return [chosen, nextMap];
538
+ });
539
+ }
540
+ bufferNotification(decoded) {
541
+ return Ref.update(this.notificationsBufferRef, (buffer) => {
542
+ const appended = [...buffer, decoded];
543
+ return appended.length > MAX_EVENT_BUFFER
544
+ ? appended.slice(-MAX_EVENT_BUFFER)
545
+ : appended;
546
+ });
547
+ }
548
+ handleDecodedNotification(decoded) {
549
+ return Effect.gen(this, function* () {
550
+ yield* this.subscribers.dispatch(decoded);
551
+ const delivered = yield* this.takeNotificationWaiter(decoded);
552
+ if (delivered !== null) {
553
+ yield* delivered.complete(decoded);
554
+ return;
555
+ }
556
+ yield* this.bufferNotification(decoded);
557
+ });
558
+ }
559
+ handleDecodedFrame(decoded) {
560
+ switch (decoded._tag) {
561
+ case "ResponseSuccess":
562
+ case "ResponseError":
563
+ return this.handleDecodedResponse(decoded);
564
+ case "ServerRequest":
565
+ return this.handleDecodedServerRequest(decoded);
566
+ case "Notification":
567
+ return this.handleDecodedNotification(decoded);
568
+ }
569
+ }
570
+ /**
571
+ * Route an inbound frame. Malformed frames are logged + dropped; notification
572
+ * frames dispatch to `onNotification` after the shape check.
573
+ */
552
574
  handleIncoming(raw) {
553
575
  return Effect.gen(this, function* () {
554
- const decodedFrames = yield* decodeFrames(raw).pipe(Effect.catchTag("MalformedFrameError", (err) => Effect.gen(this, function* () {
555
- const n = yield* Ref.updateAndGet(this.malformedRef, (c) => c + 1);
556
- if (n === 1 || n % MALFORMED_LOG_EVERY === 0) {
557
- this.options.logger?.warn(`Malformed frame (#${n}):`, err.raw.slice(0, MALFORMED_FRAME_PREVIEW_CHARS));
558
- }
559
- return null;
560
- })));
576
+ const decodedFrames = yield* decodeFrames(raw).pipe(Effect.catchTag("MalformedFrameError", (err) => this.recordMalformedFrame(err)));
561
577
  if (decodedFrames === null)
562
578
  return;
563
579
  for (const decoded of decodedFrames) {
564
- if (decoded._tag === "ResponseSuccess" ||
565
- decoded._tag === "ResponseError") {
566
- const id = decoded.id;
567
- const pending = yield* Ref.modify(this.pendingRef, (m) => {
568
- const entry = HashMap.get(m, id);
569
- return entry._tag === "Some"
570
- ? [entry.value, HashMap.remove(m, id)]
571
- : [null, m];
572
- });
573
- if (pending === null)
574
- continue;
575
- if (decoded._tag === "ResponseError") {
576
- const { error } = decoded;
577
- yield* Deferred.fail(pending, new RpcServerError({
578
- code: typeof error.code === "number"
579
- ? error.code
580
- : JSON_RPC_INTERNAL_ERROR_CODE,
581
- message: error.message ?? MSG_RPC_ERROR_FALLBACK,
582
- data: error.data,
583
- }));
584
- }
585
- else {
586
- yield* Deferred.succeed(pending, decoded.result);
587
- }
588
- continue;
589
- }
590
- if (decoded._tag === "ServerRequest") {
591
- // Task-callback request — non-blocking offer to the global
592
- // bounded queue (cutover #533). The drain fiber serializes
593
- // handler execution. If the queue is saturated, surface a
594
- // typed wire-level error so the server's `Deferred.await`
595
- // settles deterministically rather than hanging.
596
- const state = yield* Ref.get(this.stateRef);
597
- if (Option.isNone(state)) {
598
- // Reader fiber observed a frame without a corresponding
599
- // ConnState — only path here is a frame arriving between
600
- // scope-close finalization and reader-exit observation.
601
- // Drop silently: the queue + drain fiber are already gone.
602
- continue;
603
- }
604
- const offered = yield* Queue.offer(state.value.taskCallbackQueue, decoded);
605
- if (!offered) {
606
- yield* this.writeQueueFullRejection(decoded.id, state.value.write);
607
- }
608
- continue;
609
- }
610
- if (decoded._tag === "Notification") {
611
- yield* this.subscribers.dispatch(decoded);
612
- const delivered = yield* Ref.modify(this.notificationWaitersRef, (m) => {
613
- const bucket = HashMap.get(m, decoded.definition);
614
- if (bucket._tag === "None" || bucket.value.length === 0) {
615
- return [null, m];
616
- }
617
- const arr = bucket.value;
618
- const chosen = arr[arr.length - 1];
619
- const rest = arr.slice(0, -1);
620
- const nextMap = rest.length === 0
621
- ? HashMap.remove(m, decoded.definition)
622
- : HashMap.set(m, decoded.definition, rest);
623
- return [chosen, nextMap];
624
- });
625
- if (delivered !== null) {
626
- yield* delivered.complete(decoded);
627
- continue;
628
- }
629
- yield* Ref.update(this.notificationsBufferRef, (xs) => {
630
- const appended = [...xs, decoded];
631
- return appended.length > MAX_EVENT_BUFFER
632
- ? appended.slice(-MAX_EVENT_BUFFER)
633
- : appended;
634
- });
635
- }
580
+ yield* this.handleDecodedFrame(decoded);
636
581
  }
637
582
  });
638
583
  }
@@ -647,30 +592,36 @@ export class MoltZapWsClient {
647
592
  }
648
593
  });
649
594
  }
595
+ failConnectionPending(state) {
596
+ return state.jsonRpcClient.failAllPending(new NotConnectedError({ message: MSG_NOT_CONNECTED }));
597
+ }
650
598
  failAllPending(message) {
651
599
  return Effect.gen(this, function* () {
652
- const pending = yield* Ref.getAndSet(this.pendingRef, HashMap.empty());
653
- for (const [, d] of HashMap.entries(pending)) {
654
- yield* Deferred.fail(d, new NotConnectedError({ message })).pipe(Effect.ignore);
655
- }
600
+ const state = yield* Ref.get(this.stateRef);
601
+ if (Option.isNone(state))
602
+ return;
603
+ yield* state.value.jsonRpcClient.failAllPending(new NotConnectedError({ message }));
656
604
  });
657
605
  }
658
- /** Schedule a reconnect attempt. Jittered exponential backoff (1s base,
659
- * 30s cap) routed through `Effect.sleep` so `TestClock` can drive it. */
606
+ /**
607
+ * Schedule a reconnect attempt. Jittered exponential backoff (1s base,
608
+ * 30s cap) routed through `Effect.sleep` so `TestClock` can drive it.
609
+ */
660
610
  scheduleReconnect() {
661
611
  if (this.closed || this.reconnectFiber !== null)
662
612
  return;
663
- const attempt = this.connectEffect().pipe(Effect.tap((helloOk) => Effect.sync(() => {
613
+ const attempt = this.connectEffect().pipe(Effect.tap((helloOk) => Effect.gen(this, function* () {
664
614
  try {
665
615
  this.options.onReconnect?.(helloOk);
666
616
  }
667
617
  catch (err) {
668
- this.options.logger?.warn("onReconnect handler threw", err);
618
+ yield* Effect.logWarning("onReconnect handler threw", err);
669
619
  }
670
- })),
671
- // Collapse typed errors so `Schedule.exponential` can retry.
672
- Effect.mapError(() => new ReconnectAttemptFailedError({
673
- reason: "reconnect attempt failed",
620
+ })), Effect.either, Effect.flatMap(Either.match({
621
+ onLeft: () => Effect.fail(new ReconnectAttemptFailedError({
622
+ reason: "reconnect attempt failed",
623
+ })),
624
+ onRight: (value) => Effect.succeed(value),
674
625
  })));
675
626
  const backoff = Schedule.exponential(Duration.millis(BASE_RECONNECT_DELAY_MS), RECONNECT_BACKOFF_FACTOR).pipe(Schedule.either(Schedule.spaced(Duration.millis(MAX_RECONNECT_DELAY_MS))), Schedule.jittered);
676
627
  const loop = attempt.pipe(Effect.retry(backoff), Effect.asVoid, Effect.catchAll(() => Effect.void), Effect.ensuring(Effect.sync(() => {