@moltzap/client 2026.503.3 → 2026.504.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 (227) hide show
  1. package/dist/auth.d.ts +10 -1
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +8 -5
  4. package/dist/auth.js.map +1 -1
  5. package/dist/channel-core.d.ts +1 -4
  6. package/dist/channel-core.d.ts.map +1 -1
  7. package/dist/channel-core.js +0 -9
  8. package/dist/channel-core.js.map +1 -1
  9. package/dist/channel-core.test.js +90 -71
  10. package/dist/channel-core.test.js.map +1 -1
  11. package/dist/cli/commands/agents.d.ts.map +1 -1
  12. package/dist/cli/commands/agents.js +5 -3
  13. package/dist/cli/commands/agents.js.map +1 -1
  14. package/dist/cli/commands/apps.d.ts +9 -27
  15. package/dist/cli/commands/apps.d.ts.map +1 -1
  16. package/dist/cli/commands/apps.js +36 -57
  17. package/dist/cli/commands/apps.js.map +1 -1
  18. package/dist/cli/commands/apps.test.js +63 -75
  19. package/dist/cli/commands/apps.test.js.map +1 -1
  20. package/dist/cli/commands/contacts.d.ts +1 -5
  21. package/dist/cli/commands/contacts.d.ts.map +1 -1
  22. package/dist/cli/commands/contacts.js +14 -27
  23. package/dist/cli/commands/contacts.js.map +1 -1
  24. package/dist/cli/commands/conversations-v2.test.js +34 -29
  25. package/dist/cli/commands/conversations-v2.test.js.map +1 -1
  26. package/dist/cli/commands/conversations.d.ts +7 -3
  27. package/dist/cli/commands/conversations.d.ts.map +1 -1
  28. package/dist/cli/commands/conversations.js +63 -48
  29. package/dist/cli/commands/conversations.js.map +1 -1
  30. package/dist/cli/commands/messages.d.ts +7 -3
  31. package/dist/cli/commands/messages.d.ts.map +1 -1
  32. package/dist/cli/commands/messages.js +13 -15
  33. package/dist/cli/commands/messages.js.map +1 -1
  34. package/dist/cli/commands/messages.test.js +30 -31
  35. package/dist/cli/commands/messages.test.js.map +1 -1
  36. package/dist/cli/commands/ping.d.ts +9 -0
  37. package/dist/cli/commands/ping.d.ts.map +1 -1
  38. package/dist/cli/commands/ping.js +17 -4
  39. package/dist/cli/commands/ping.js.map +1 -1
  40. package/dist/cli/commands/presence.d.ts.map +1 -1
  41. package/dist/cli/commands/presence.js +6 -4
  42. package/dist/cli/commands/presence.js.map +1 -1
  43. package/dist/cli/commands/register.d.ts.map +1 -1
  44. package/dist/cli/commands/register.js +2 -1
  45. package/dist/cli/commands/register.js.map +1 -1
  46. package/dist/cli/commands/send.d.ts.map +1 -1
  47. package/dist/cli/commands/send.js +22 -11
  48. package/dist/cli/commands/send.js.map +1 -1
  49. package/dist/cli/commands/send.test.js +12 -9
  50. package/dist/cli/commands/send.test.js.map +1 -1
  51. package/dist/cli/commands/status.js +2 -2
  52. package/dist/cli/commands/status.js.map +1 -1
  53. package/dist/cli/commands/test-transport.d.ts +10 -0
  54. package/dist/cli/commands/test-transport.d.ts.map +1 -0
  55. package/dist/cli/commands/test-transport.js +29 -0
  56. package/dist/cli/commands/test-transport.js.map +1 -0
  57. package/dist/cli/commands/whoami.d.ts.map +1 -1
  58. package/dist/cli/commands/whoami.js +5 -1
  59. package/dist/cli/commands/whoami.js.map +1 -1
  60. package/dist/cli/config.d.ts +42 -6
  61. package/dist/cli/config.d.ts.map +1 -1
  62. package/dist/cli/config.js +91 -27
  63. package/dist/cli/config.js.map +1 -1
  64. package/dist/cli/http-client.d.ts +11 -1
  65. package/dist/cli/http-client.d.ts.map +1 -1
  66. package/dist/cli/http-client.js +13 -4
  67. package/dist/cli/http-client.js.map +1 -1
  68. package/dist/cli/index.d.ts.map +1 -1
  69. package/dist/cli/index.js +2 -3
  70. package/dist/cli/index.js.map +1 -1
  71. package/dist/cli/profile.d.ts +3 -4
  72. package/dist/cli/profile.d.ts.map +1 -1
  73. package/dist/cli/profile.js +42 -29
  74. package/dist/cli/profile.js.map +1 -1
  75. package/dist/cli/runtime.d.ts.map +1 -1
  76. package/dist/cli/runtime.js +9 -4
  77. package/dist/cli/runtime.js.map +1 -1
  78. package/dist/cli/socket-client.d.ts +29 -4
  79. package/dist/cli/socket-client.d.ts.map +1 -1
  80. package/dist/cli/socket-client.js +52 -21
  81. package/dist/cli/socket-client.js.map +1 -1
  82. package/dist/cli/transport.d.ts +6 -9
  83. package/dist/cli/transport.d.ts.map +1 -1
  84. package/dist/cli/transport.js +47 -37
  85. package/dist/cli/transport.js.map +1 -1
  86. package/dist/cli/transport.test.js +19 -16
  87. package/dist/cli/transport.test.js.map +1 -1
  88. package/dist/index.d.ts +2 -3
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +1 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/internal/__tests__/app-callback-partition-key.test.d.ts +2 -0
  93. package/dist/internal/__tests__/app-callback-partition-key.test.d.ts.map +1 -0
  94. package/dist/internal/__tests__/app-callback-partition-key.test.js +33 -0
  95. package/dist/internal/__tests__/app-callback-partition-key.test.js.map +1 -0
  96. package/dist/internal/__tests__/app-callback-partition-worker.test.d.ts +2 -0
  97. package/dist/internal/__tests__/app-callback-partition-worker.test.d.ts.map +1 -0
  98. package/dist/internal/__tests__/{s2c-partition-worker.test.js → app-callback-partition-worker.test.js} +6 -8
  99. package/dist/internal/__tests__/app-callback-partition-worker.test.js.map +1 -0
  100. package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts +2 -0
  101. package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts.map +1 -0
  102. package/dist/internal/__tests__/{s2c-partitioned-dispatcher-real-ws.test.js → app-callback-partitioned-dispatcher-real-ws.test.js} +69 -93
  103. package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.js.map +1 -0
  104. package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.d.ts +2 -0
  105. package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.d.ts.map +1 -0
  106. package/dist/internal/__tests__/{s2c-partitioned-dispatcher.test.js → app-callback-partitioned-dispatcher.test.js} +36 -69
  107. package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.js.map +1 -0
  108. package/dist/internal/__tests__/app-callback-test-requests.d.ts +58 -0
  109. package/dist/internal/__tests__/app-callback-test-requests.d.ts.map +1 -0
  110. package/dist/internal/__tests__/app-callback-test-requests.js +124 -0
  111. package/dist/internal/__tests__/app-callback-test-requests.js.map +1 -0
  112. package/dist/internal/{s2c-dispatcher-errors.d.ts → app-callback-dispatcher-errors.d.ts} +5 -21
  113. package/dist/internal/app-callback-dispatcher-errors.d.ts.map +1 -0
  114. package/dist/internal/{s2c-dispatcher-errors.js → app-callback-dispatcher-errors.js} +3 -13
  115. package/dist/internal/app-callback-dispatcher-errors.js.map +1 -0
  116. package/dist/internal/app-callback-partition-key.d.ts +68 -0
  117. package/dist/internal/app-callback-partition-key.d.ts.map +1 -0
  118. package/dist/internal/app-callback-partition-key.js +90 -0
  119. package/dist/internal/app-callback-partition-key.js.map +1 -0
  120. package/dist/internal/{s2c-partition-worker.d.ts → app-callback-partition-worker.d.ts} +5 -5
  121. package/dist/internal/app-callback-partition-worker.d.ts.map +1 -0
  122. package/dist/internal/{s2c-partition-worker.js → app-callback-partition-worker.js} +5 -5
  123. package/dist/internal/app-callback-partition-worker.js.map +1 -0
  124. package/dist/internal/{s2c-partitioned-dispatcher.d.ts → app-callback-partitioned-dispatcher.d.ts} +9 -10
  125. package/dist/internal/app-callback-partitioned-dispatcher.d.ts.map +1 -0
  126. package/dist/internal/{s2c-partitioned-dispatcher.js → app-callback-partitioned-dispatcher.js} +13 -21
  127. package/dist/internal/app-callback-partitioned-dispatcher.js.map +1 -0
  128. package/dist/runtime/close-info.d.ts +15 -16
  129. package/dist/runtime/close-info.d.ts.map +1 -1
  130. package/dist/runtime/close-info.js +12 -8
  131. package/dist/runtime/close-info.js.map +1 -1
  132. package/dist/runtime/errors.d.ts +2 -28
  133. package/dist/runtime/errors.d.ts.map +1 -1
  134. package/dist/runtime/errors.js +0 -9
  135. package/dist/runtime/errors.js.map +1 -1
  136. package/dist/runtime/errors.test.js +4 -3
  137. package/dist/runtime/errors.test.js.map +1 -1
  138. package/dist/runtime/frame.d.ts +17 -18
  139. package/dist/runtime/frame.d.ts.map +1 -1
  140. package/dist/runtime/frame.js +86 -31
  141. package/dist/runtime/frame.js.map +1 -1
  142. package/dist/runtime/frame.test.js +15 -11
  143. package/dist/runtime/frame.test.js.map +1 -1
  144. package/dist/runtime/index.d.ts +1 -1
  145. package/dist/runtime/index.d.ts.map +1 -1
  146. package/dist/runtime/index.js +1 -1
  147. package/dist/runtime/index.js.map +1 -1
  148. package/dist/runtime/local-service-commands.d.ts +7 -0
  149. package/dist/runtime/local-service-commands.d.ts.map +1 -0
  150. package/dist/runtime/local-service-commands.js +6 -0
  151. package/dist/runtime/local-service-commands.js.map +1 -0
  152. package/dist/runtime/runtime.test.js +3 -3
  153. package/dist/runtime/runtime.test.js.map +1 -1
  154. package/dist/runtime/subscribers.d.ts +37 -33
  155. package/dist/runtime/subscribers.d.ts.map +1 -1
  156. package/dist/runtime/subscribers.js +22 -21
  157. package/dist/runtime/subscribers.js.map +1 -1
  158. package/dist/runtime/subscribers.test.js +50 -31
  159. package/dist/runtime/subscribers.test.js.map +1 -1
  160. package/dist/service.d.ts +24 -39
  161. package/dist/service.d.ts.map +1 -1
  162. package/dist/service.js +190 -171
  163. package/dist/service.js.map +1 -1
  164. package/dist/service.test.js +117 -175
  165. package/dist/service.test.js.map +1 -1
  166. package/dist/test/index.d.ts +9 -1
  167. package/dist/test/index.d.ts.map +1 -1
  168. package/dist/test/index.js +14 -3
  169. package/dist/test/index.js.map +1 -1
  170. package/dist/test-utils/channel-service-fixture.d.ts +1 -2
  171. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  172. package/dist/test-utils/channel-service-fixture.js +28 -20
  173. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  174. package/dist/test-utils/conformance-adapter.d.ts +2 -2
  175. package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
  176. package/dist/test-utils/conformance-adapter.js +78 -54
  177. package/dist/test-utils/conformance-adapter.js.map +1 -1
  178. package/dist/test-utils/fake-service.d.ts +14 -39
  179. package/dist/test-utils/fake-service.d.ts.map +1 -1
  180. package/dist/test-utils/fake-service.js +33 -39
  181. package/dist/test-utils/fake-service.js.map +1 -1
  182. package/dist/test-utils/ids.d.ts +9 -0
  183. package/dist/test-utils/ids.d.ts.map +1 -0
  184. package/dist/test-utils/ids.js +52 -0
  185. package/dist/test-utils/ids.js.map +1 -0
  186. package/dist/test-utils/index.d.ts +10 -2
  187. package/dist/test-utils/index.d.ts.map +1 -1
  188. package/dist/test-utils/index.js +25 -9
  189. package/dist/test-utils/index.js.map +1 -1
  190. package/dist/ws-client.d.ts +59 -64
  191. package/dist/ws-client.d.ts.map +1 -1
  192. package/dist/ws-client.js +228 -175
  193. package/dist/ws-client.js.map +1 -1
  194. package/dist/ws-client.test.js +190 -176
  195. package/dist/ws-client.test.js.map +1 -1
  196. package/package.json +3 -3
  197. package/dist/cli/commands/permissions.d.ts +0 -66
  198. package/dist/cli/commands/permissions.d.ts.map +0 -1
  199. package/dist/cli/commands/permissions.js +0 -101
  200. package/dist/cli/commands/permissions.js.map +0 -1
  201. package/dist/cli/commands/permissions.test.d.ts +0 -2
  202. package/dist/cli/commands/permissions.test.d.ts.map +0 -1
  203. package/dist/cli/commands/permissions.test.js +0 -126
  204. package/dist/cli/commands/permissions.test.js.map +0 -1
  205. package/dist/internal/__tests__/s2c-partition-key.test.d.ts +0 -2
  206. package/dist/internal/__tests__/s2c-partition-key.test.d.ts.map +0 -1
  207. package/dist/internal/__tests__/s2c-partition-key.test.js +0 -230
  208. package/dist/internal/__tests__/s2c-partition-key.test.js.map +0 -1
  209. package/dist/internal/__tests__/s2c-partition-worker.test.d.ts +0 -2
  210. package/dist/internal/__tests__/s2c-partition-worker.test.d.ts.map +0 -1
  211. package/dist/internal/__tests__/s2c-partition-worker.test.js.map +0 -1
  212. package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.d.ts +0 -2
  213. package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.d.ts.map +0 -1
  214. package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.js.map +0 -1
  215. package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.d.ts +0 -2
  216. package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.d.ts.map +0 -1
  217. package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.js.map +0 -1
  218. package/dist/internal/s2c-dispatcher-errors.d.ts.map +0 -1
  219. package/dist/internal/s2c-dispatcher-errors.js.map +0 -1
  220. package/dist/internal/s2c-partition-key.d.ts +0 -71
  221. package/dist/internal/s2c-partition-key.d.ts.map +0 -1
  222. package/dist/internal/s2c-partition-key.js +0 -140
  223. package/dist/internal/s2c-partition-key.js.map +0 -1
  224. package/dist/internal/s2c-partition-worker.d.ts.map +0 -1
  225. package/dist/internal/s2c-partition-worker.js.map +0 -1
  226. package/dist/internal/s2c-partitioned-dispatcher.d.ts.map +0 -1
  227. package/dist/internal/s2c-partitioned-dispatcher.js.map +0 -1
package/dist/ws-client.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import * as Socket from "@effect/platform/Socket";
2
2
  import * as NodeSocket from "@effect/platform-node/NodeSocket";
3
- import { Cause, Deferred, Duration, Effect, Exit, Fiber, HashMap, ManagedRuntime, Option, Ref, Schedule, Scope, } from "effect";
4
- import { PROTOCOL_VERSION, responseFrame, } from "@moltzap/protocol";
5
- import { DuplicateServerRpcHandlerError, NotConnectedError, RpcServerError, RpcTimeoutError, } from "./runtime/errors.js";
3
+ import { Cause, Data, Deferred, Duration, Either, Effect, Exit, Fiber, HashMap, ManagedRuntime, Option, Ref, Schedule, Scope, } from "effect";
4
+ import { PROTOCOL_VERSION, Connect, decodeRpcResult, jsonRpcStringId, NotConnectedError, requestFrame, responseFrame, RpcServerError, RpcTimeoutError, } from "@moltzap/protocol";
5
+ import { DuplicateServerRpcHandlerError } from "./runtime/errors.js";
6
6
  import { decodeFrames } from "./runtime/frame.js";
7
7
  import { makeSubscriberRegistry, } from "./runtime/subscribers.js";
8
8
  import { extractCloseInfo } from "./runtime/close-info.js";
9
- import { makePartitionedDispatcher, } from "./internal/s2c-partitioned-dispatcher.js";
9
+ import { makePartitionedDispatcher, } from "./internal/app-callback-partitioned-dispatcher.js";
10
+ import { PartitionLimitError, PartitionQueueFullError, } from "./internal/app-callback-dispatcher-errors.js";
10
11
  /**
11
12
  * Default per-RPC timeout. Exported so tests driving `TestClock` can match
12
13
  * exactly — keeps tests from silently drifting if this changes.
@@ -15,29 +16,41 @@ export const RPC_TIMEOUT_MS = 30_000;
15
16
  /** Reconnect backoff: 1s base, doubling per attempt up to the cap. */
16
17
  const BASE_RECONNECT_DELAY_MS = 1000;
17
18
  const MAX_RECONNECT_DELAY_MS = 30_000;
19
+ const RECONNECT_BACKOFF_FACTOR = 2;
20
+ const NORMAL_CLOSE_CODE = 1000;
21
+ const EVENT_WAIT_TIMEOUT_MS = 5000;
22
+ const WEB_SOCKET_OPEN_TIMEOUT_SECONDS = 10;
23
+ const MALFORMED_FRAME_PREVIEW_CHARS = 200;
24
+ const JSON_RPC_INTERNAL_ERROR_CODE = -32603;
18
25
  /**
19
26
  * Log 1-of-N malformed frames. A misbehaving server could flood us otherwise;
20
27
  * the counter in the log makes it clear how many we've dropped between logs.
21
28
  */
22
29
  const MALFORMED_LOG_EVERY = 50;
23
30
  /**
24
- * Cap on the per-client event buffer. Any frame that has no live
25
- * `waitForEvent` awaiter lands here until someone drains it. Excess
31
+ * Cap on the per-client notification buffer. Any frame that has no live
32
+ * `waitForNotification` awaiter lands here until someone drains it. Excess
26
33
  * frames are evicted FIFO so a slow consumer can't leak memory.
27
34
  */
28
35
  const MAX_EVENT_BUFFER = 1000;
29
36
  const MSG_NOT_CONNECTED = "WebSocket not connected";
30
37
  const MSG_RPC_ERROR_FALLBACK = "RPC error";
31
38
  const UTF8_DECODER = new TextDecoder("utf-8");
32
- /** Drop `waiter` from its event-name bucket, pruning an empty bucket. */
33
- function removeWaiter(m, eventName, waiter) {
34
- const bucket = HashMap.get(m, eventName);
39
+ const makeNotConnectedError = () => new NotConnectedError({ message: MSG_NOT_CONNECTED });
40
+ class ReconnectAttemptFailedError extends Data.TaggedError("ReconnectAttemptFailedError") {
41
+ }
42
+ function notificationMatches(definition, notification) {
43
+ return notification.definition === definition;
44
+ }
45
+ /** Drop `waiter` from its notification-definition bucket, pruning an empty bucket. */
46
+ function removeWaiter(m, definition, waiter) {
47
+ const bucket = HashMap.get(m, definition);
35
48
  if (bucket._tag === "None")
36
49
  return m;
37
50
  const filtered = bucket.value.filter((w) => w !== waiter);
38
51
  return filtered.length === 0
39
- ? HashMap.remove(m, eventName)
40
- : HashMap.set(m, eventName, filtered);
52
+ ? HashMap.remove(m, definition)
53
+ : HashMap.set(m, definition, filtered);
41
54
  }
42
55
  /**
43
56
  * WebSocket lifecycle: open → auth/connect → active. On disconnect,
@@ -56,17 +69,15 @@ export class MoltZapWsClient {
56
69
  pendingRef;
57
70
  stateRef;
58
71
  malformedRef;
59
- eventsBufferRef;
72
+ notificationsBufferRef;
60
73
  /**
61
- * Waiters keyed by event name. Each bucket is a FIFO stack: delivery
62
- * pops the most recently registered waiter (the tail). Keying by event
63
- * name keeps dispatch O(1) per inbound frame regardless of total
64
- * outstanding waiters.
74
+ * Waiters keyed by notification descriptor identity. Each bucket is a FIFO
75
+ * stack: delivery pops the most recently registered waiter (the tail).
65
76
  */
66
- eventWaitersRef;
77
+ notificationWaitersRef;
67
78
  runtime;
68
79
  /**
69
- * Per-subscription event registry. Spec #222 §5.3 (C4 + the
80
+ * Per-subscription notification registry. Spec #222 §5.3 (C4 + the
70
81
  * `RealClientEventSubscriber.subscribe` filter stub). Constructed
71
82
  * synchronously alongside the other Refs; `MoltZapWsClient.subscribe`
72
83
  * delegates to it directly.
@@ -76,9 +87,9 @@ export class MoltZapWsClient {
76
87
  * Per-method handler registry for server-initiated RPCs. Survives
77
88
  * reconnects so apps register once and re-attach automatically when the
78
89
  * socket comes back. Each entry is invoked by the per-connection
79
- * dispatcher fiber when an s2c request frame arrives.
90
+ * dispatcher fiber when an appCallback request frame arrives.
80
91
  */
81
- s2cHandlersRef;
92
+ appCallbackHandlersRef;
82
93
  requestCounter = 0;
83
94
  closed = false;
84
95
  reconnectFiber = null;
@@ -89,15 +100,15 @@ export class MoltZapWsClient {
89
100
  this.pendingRef = this.runtime.runSync(Ref.make(HashMap.empty()));
90
101
  this.stateRef = this.runtime.runSync(Ref.make(Option.none()));
91
102
  this.malformedRef = this.runtime.runSync(Ref.make(0));
92
- this.eventsBufferRef = this.runtime.runSync(Ref.make([]));
93
- this.eventWaitersRef = this.runtime.runSync(Ref.make(HashMap.empty()));
103
+ this.notificationsBufferRef = this.runtime.runSync(Ref.make([]));
104
+ this.notificationWaitersRef = this.runtime.runSync(Ref.make(HashMap.empty()));
94
105
  // Registry construction is `Effect<…, never>`; running it sync here
95
106
  // matches every other Ref initializer in this constructor and
96
107
  // keeps `subscribers` non-nullable inside the class.
97
108
  this.subscribers = this.runtime.runSync(makeSubscriberRegistry({
98
109
  warn: (...args) => this.options.logger?.warn(...args),
99
110
  }));
100
- this.s2cHandlersRef = this.runtime.runSync(Ref.make(HashMap.empty()));
111
+ this.appCallbackHandlersRef = this.runtime.runSync(Ref.make(HashMap.empty()));
101
112
  }
102
113
  /**
103
114
  * Register a handler for a server-initiated RPC method. Survives
@@ -109,18 +120,21 @@ export class MoltZapWsClient {
109
120
  *
110
121
  * The dispatcher fiber forked at `connect()` time picks up handlers via
111
122
  * `Ref.get` per-frame, so a registration made BEFORE `connect()` is
112
- * visible to the very first inbound s2c request, and a registration
123
+ * visible to the very first inbound appCallback request, and a registration
113
124
  * made AFTER `connect()` takes effect on the next inbound frame.
114
125
  */
115
- handleServerRpc(method, handler) {
126
+ handleServerRpc(definition, handler) {
116
127
  return Effect.gen(this, function* () {
117
- const swapped = yield* Ref.modify(this.s2cHandlersRef, (m) => {
118
- if (HashMap.has(m, method))
128
+ const swapped = yield* Ref.modify(this.appCallbackHandlersRef, (m) => {
129
+ if (HashMap.has(m, definition))
119
130
  return [false, m];
120
- return [true, HashMap.set(m, method, handler)];
131
+ return [
132
+ true,
133
+ HashMap.set(m, definition, handler),
134
+ ];
121
135
  });
122
136
  if (!swapped) {
123
- return yield* Effect.fail(new DuplicateServerRpcHandlerError({ method }));
137
+ return yield* Effect.fail(new DuplicateServerRpcHandlerError({ method: definition.name }));
124
138
  }
125
139
  });
126
140
  }
@@ -132,7 +146,7 @@ export class MoltZapWsClient {
132
146
  connect() {
133
147
  return Effect.suspend(() => {
134
148
  if (this.closed) {
135
- return Effect.fail(new NotConnectedError({ message: MSG_NOT_CONNECTED }));
149
+ return Effect.fail(makeNotConnectedError());
136
150
  }
137
151
  return this.connectEffect().pipe(
138
152
  // `makeWebSocket` requires `Socket.WebSocketConstructor`; our
@@ -141,18 +155,48 @@ export class MoltZapWsClient {
141
155
  Effect.provide(NodeSocket.layerWebSocketConstructor));
142
156
  });
143
157
  }
144
- sendRpc(method, params, opts) {
145
- const methodName = typeof method === "string" ? method : method.name;
146
- return this.sendRpcEffect(methodName, params, opts);
158
+ /**
159
+ * Send an RPC. Fails with a typed error:
160
+ * - `NotConnectedError` if the socket isn't OPEN or closes mid-RPC
161
+ * - `RpcTimeoutError` after `RPC_TIMEOUT_MS` — no automatic retry
162
+ * - `RpcServerError` on a typed server-error frame
163
+ *
164
+ * Descriptor-backed RPC call. Callers pass the protocol descriptor, and the
165
+ * client extracts the wire method only inside the encoder path.
166
+ */
167
+ sendRpc(definition, params, opts) {
168
+ return this.sendRpcEffect(definition, params, opts).pipe(Effect.flatMap((result) => definition.validateResult(result)
169
+ ? Effect.succeed(result)
170
+ : Effect.fail(new RpcServerError({
171
+ code: JSON_RPC_INTERNAL_ERROR_CODE,
172
+ message: `Invalid result for method: ${definition.name}`,
173
+ data: result,
174
+ }))));
147
175
  }
148
- sendRpcTracked(method, params) {
149
- const methodName = typeof method === "string" ? method : method.name;
150
- return this.sendRpcTrackedEffect(methodName, params);
176
+ /**
177
+ * Send an RPC and surface the outbound request id alongside the
178
+ * response envelope `type` and `result`. Spec #222 §5.1–5.2:
179
+ * un-vacuates B4 (request-id tracking) and V5 (response-type
180
+ * exposure). Mirrors `sendRpc`'s descriptor-backed call shape.
181
+ *
182
+ * Invariant 3 (spec #222 §4): the returned `id` is the same identity
183
+ * minted inside the existing `rpc-${++counter}` site — no parallel
184
+ * counter, no mirror. The single-id pre-condition is what makes B4 a
185
+ * real check rather than a tautology.
186
+ */
187
+ sendRpcTracked(definition, params) {
188
+ return this.sendRpcTrackedEffect(definition, params).pipe(Effect.flatMap((tracked) => definition.validateResult(tracked.result)
189
+ ? Effect.succeed({ id: tracked.id, result: tracked.result })
190
+ : Effect.fail(new RpcServerError({
191
+ code: JSON_RPC_INTERNAL_ERROR_CODE,
192
+ message: `Invalid result for method: ${definition.name}`,
193
+ data: tracked.result,
194
+ }))));
151
195
  }
152
196
  /**
153
- * Register a per-subscription event handler. Spec #222 §5.3 + OQ-2
197
+ * Register a per-subscription notification handler. Spec #222 §5.3 + OQ-2
154
198
  * (A): filter grammar is the three-field `SubscriptionFilter`
155
- * (`emissionTag` / `conversationId` / `eventNamePrefix`). Returns a
199
+ * (`emissionTag` / `conversationId` / `notificationNamePrefix`). Returns a
156
200
  * handle whose `unsubscribe` Effect drops delivery starting with the
157
201
  * next inbound frame (OQ-3 A snapshot semantics).
158
202
  *
@@ -164,7 +208,7 @@ export class MoltZapWsClient {
164
208
  subscribe(filter, handler) {
165
209
  return Effect.suspend(() => {
166
210
  if (this.closed) {
167
- return Effect.fail(new NotConnectedError({ message: MSG_NOT_CONNECTED }));
211
+ return Effect.fail(makeNotConnectedError());
168
212
  }
169
213
  return this.subscribers.register(filter, handler);
170
214
  });
@@ -179,6 +223,7 @@ export class MoltZapWsClient {
179
223
  return Effect.gen(this, function* () {
180
224
  if (this.closed)
181
225
  return;
226
+ const hasCompletedHandshake = this._helloOk !== null;
182
227
  this.closed = true;
183
228
  this._helloOk = null;
184
229
  if (this.reconnectFiber !== null) {
@@ -186,19 +231,22 @@ export class MoltZapWsClient {
186
231
  this.reconnectFiber = null;
187
232
  yield* Effect.forkDaemon(Fiber.interrupt(f));
188
233
  }
189
- yield* Effect.all([
190
- this.failAllPending(MSG_NOT_CONNECTED),
191
- this.failAllEventWaiters(MSG_NOT_CONNECTED),
192
- // Drop every live subscription so handlers stop firing once
193
- // the client is permanently torn down. Idempotent.
194
- this.subscribers.closeAll,
195
- ], { concurrency: "unbounded" });
234
+ yield* this.failAllPending(MSG_NOT_CONNECTED);
235
+ yield* this.failAllNotificationWaiters(MSG_NOT_CONNECTED);
236
+ // Drop every live subscription so handlers stop firing once
237
+ // the client is permanently torn down. Idempotent.
238
+ yield* this.subscribers.closeAll;
196
239
  const state = yield* Ref.getAndSet(this.stateRef, Option.none());
197
240
  if (Option.isSome(state)) {
198
- yield* state.value
199
- .write(new Socket.CloseEvent(1000, "normal"))
200
- .pipe(Effect.orDie);
201
- yield* Scope.close(state.value.scope, Exit.void);
241
+ if (hasCompletedHandshake) {
242
+ yield* state.value
243
+ .write(new Socket.CloseEvent(NORMAL_CLOSE_CODE, "normal"))
244
+ .pipe(Effect.orDie);
245
+ yield* Scope.close(state.value.scope, Exit.void);
246
+ }
247
+ else {
248
+ this.runtime.runFork(Scope.close(state.value.scope, Exit.void));
249
+ }
202
250
  // The partitioned dispatcher's Scope is NOT bound to the
203
251
  // socket Scope (see ConnState doc): tear it down via runFork
204
252
  // so this Effect remains sync-runnable for callers using
@@ -211,40 +259,47 @@ export class MoltZapWsClient {
211
259
  void this.runtime.dispose();
212
260
  })));
213
261
  }
214
- /** Wait for the next inbound event whose `event` field equals `eventName`.
262
+ /** Wait for the next inbound notification matching `definition`.
215
263
  * Consumes a buffered match if present; otherwise awaits the next match
216
264
  * with a per-call timeout. */
217
- waitForEvent(eventName, timeoutMs = 5000) {
265
+ waitForNotification(definition, timeoutMs = EVENT_WAIT_TIMEOUT_MS) {
218
266
  return Effect.gen(this, function* () {
219
- const buffered = yield* Ref.modify(this.eventsBufferRef, (events) => {
220
- const idx = events.findIndex((e) => e.event === eventName);
221
- if (idx === -1)
222
- return [null, events];
223
- const chosen = events[idx];
224
- const next = [...events.slice(0, idx), ...events.slice(idx + 1)];
225
- return [chosen, next];
267
+ const buffered = yield* Ref.modify(this.notificationsBufferRef, (frames) => {
268
+ for (const [idx, frame] of frames.entries()) {
269
+ if (!notificationMatches(definition, frame))
270
+ continue;
271
+ const next = [...frames.slice(0, idx), ...frames.slice(idx + 1)];
272
+ return [frame, next];
273
+ }
274
+ return [null, frames];
226
275
  });
227
276
  if (buffered !== null)
228
277
  return buffered;
229
278
  const deferred = yield* Deferred.make();
230
- const waiter = { eventName, deferred };
231
- yield* Ref.update(this.eventWaitersRef, (m) => {
232
- const existing = HashMap.get(m, eventName);
279
+ const waiter = {
280
+ definition,
281
+ complete: (notification) => notificationMatches(definition, notification)
282
+ ? Deferred.succeed(deferred, notification).pipe(Effect.asVoid)
283
+ : Effect.void,
284
+ fail: (error) => Deferred.fail(deferred, error).pipe(Effect.asVoid),
285
+ };
286
+ yield* Ref.update(this.notificationWaitersRef, (m) => {
287
+ const existing = HashMap.get(m, definition);
233
288
  const next = existing._tag === "Some" ? [...existing.value, waiter] : [waiter];
234
- return HashMap.set(m, eventName, next);
289
+ return HashMap.set(m, definition, next);
235
290
  });
236
291
  return yield* Deferred.await(deferred).pipe(Effect.timeoutFail({
237
292
  duration: `${timeoutMs} millis`,
238
- onTimeout: () => new Error(`Timeout waiting for event: ${eventName}`),
293
+ onTimeout: () => new Error(`Timeout waiting for notification: ${definition.name}`),
239
294
  }), Effect.onExit((exit) => exit._tag === "Failure"
240
- ? Ref.update(this.eventWaitersRef, (m) => removeWaiter(m, eventName, waiter))
295
+ ? Ref.update(this.notificationWaitersRef, (m) => removeWaiter(m, definition, waiter))
241
296
  : Effect.void));
242
297
  });
243
298
  }
244
- /** Return all buffered events and clear the buffer. Synchronous. */
245
- drainEvents() {
246
- const snapshot = this.runtime.runSync(Ref.get(this.eventsBufferRef));
247
- this.runtime.runSync(Ref.set(this.eventsBufferRef, []));
299
+ /** Return all buffered notifications and clear the buffer. Synchronous. */
300
+ drainNotifications() {
301
+ const snapshot = this.runtime.runSync(Ref.get(this.notificationsBufferRef));
302
+ this.runtime.runSync(Ref.set(this.notificationsBufferRef, []));
248
303
  return [...snapshot];
249
304
  }
250
305
  /** Close the socket without marking as permanently closed, triggering reconnection. */
@@ -282,13 +337,19 @@ export class MoltZapWsClient {
282
337
  const scope = yield* Scope.make();
283
338
  // Map Socket open failures (SocketGenericError / SocketCloseError) to
284
339
  // NotConnectedError so callers see a single typed error.
285
- const socket = yield* Scope.extend(Socket.makeWebSocket(url, { openTimeout: Duration.seconds(10) }), scope).pipe(Effect.catchAllCause((cause) => Effect.zipRight(Effect.sync(() => this.options.logger?.warn("WebSocket open failed", cause)), Scope.close(scope, Exit.void).pipe(Effect.zipRight(Effect.fail(new NotConnectedError({ message: MSG_NOT_CONNECTED })))))));
340
+ const openTimeout = Duration.seconds(WEB_SOCKET_OPEN_TIMEOUT_SECONDS);
341
+ const socket = yield* Scope.extend(Socket.makeWebSocket(url, { openTimeout }), scope).pipe(Effect.timeoutFail({
342
+ duration: openTimeout,
343
+ onTimeout: makeNotConnectedError,
344
+ }), Effect.catchAllCause((cause) => Effect.zipRight(Effect.sync(() => this.options.logger?.warn("WebSocket open failed", cause)), Effect.sync(() => {
345
+ this.runtime.runFork(Scope.close(scope, Exit.void));
346
+ }).pipe(Effect.zipRight(Effect.fail(makeNotConnectedError()))))));
286
347
  const write = yield* Scope.extend(socket.writer, scope);
287
348
  // Settled first by whichever fires: the auth/connect response, or
288
349
  // reader-fiber exit on any close/error before handshake.
289
350
  const handshakeSettled = yield* Deferred.make();
290
- // Spec #356 — partitioned s2c dispatcher. Replaces the pre-#356
291
- // single `Stream.runForEach`-driven `s2cInboundQueue` with a
351
+ // Spec #356 — partitioned appCallback dispatcher. Replaces the pre-#356
352
+ // single `Stream.runForEach`-driven `appCallbackInboundQueue` with a
292
353
  // partition router keyed on `(sessionId, conversationId,
293
354
  // hookKind)`. Each tuple owns one bounded queue + one drain
294
355
  // fiber; cross-tuple offers run on independent fibers, so a
@@ -309,8 +370,8 @@ export class MoltZapWsClient {
309
370
  const dispatcher = yield* makePartitionedDispatcher({
310
371
  handle: (req) => this.dispatchInboundServerRequest(req, write),
311
372
  scope: dispatcherScope,
312
- ...(this.options.s2cDispatcher !== undefined
313
- ? { config: this.options.s2cDispatcher }
373
+ ...(this.options.appCallbackDispatcher !== undefined
374
+ ? { config: this.options.appCallbackDispatcher }
314
375
  : {}),
315
376
  ...(this.options.logger !== undefined
316
377
  ? { logger: this.options.logger }
@@ -329,11 +390,11 @@ export class MoltZapWsClient {
329
390
  this._helloOk = null;
330
391
  yield* this.failAllPending(MSG_NOT_CONNECTED);
331
392
  // Unblock any `connect()` still awaiting the handshake.
332
- yield* Deferred.fail(handshakeSettled, new NotConnectedError({ message: MSG_NOT_CONNECTED })).pipe(Effect.ignore);
333
- // Clear connection state BEFORE the s2c teardown so
393
+ yield* Deferred.fail(handshakeSettled, makeNotConnectedError()).pipe(Effect.ignore);
394
+ // Clear connection state BEFORE the appCallback teardown so
334
395
  // `sendRpc` and observers see the closed state immediately.
335
396
  // Awaiting `Fiber.interrupt` would block this branch on a
336
- // slow s2c handler still draining (codex P2).
397
+ // slow appCallback handler still draining (codex P2).
337
398
  yield* Ref.set(this.stateRef, Option.none());
338
399
  // Tear down the partitioned dispatcher on socket-level
339
400
  // close (e.g. server-initiated). `close()` /
@@ -374,7 +435,7 @@ export class MoltZapWsClient {
374
435
  dispatcher,
375
436
  dispatcherScope,
376
437
  }));
377
- const authEffect = this.sendRpcEffect("auth/connect", {
438
+ const authEffect = this.sendRpc(Connect, {
378
439
  agentKey: this.options.agentKey,
379
440
  minProtocol: PROTOCOL_VERSION,
380
441
  maxProtocol: PROTOCOL_VERSION,
@@ -392,26 +453,20 @@ export class MoltZapWsClient {
392
453
  /**
393
454
  * Tracked variant of `sendRpcEffect`. Reuses the same Deferred /
394
455
  * pendingRef plumbing — the only difference is the resolved value:
395
- * `{id, type: "response", result}` instead of bare `result`. This
456
+ * `{id, result}` instead of bare `result`. This
396
457
  * keeps Invariant 3 (single id source) trivially: we mint `id` once
397
458
  * inside this body, register the Deferred under that id, and return
398
459
  * the same id to the caller alongside the result.
399
460
  */
400
- sendRpcTrackedEffect(method, params, opts) {
461
+ sendRpcTrackedEffect(definition, params, opts) {
401
462
  return Effect.gen(this, function* () {
463
+ const method = definition.name;
402
464
  const state = yield* Ref.get(this.stateRef);
403
465
  if (Option.isNone(state)) {
404
- return yield* Effect.fail(new NotConnectedError({ message: MSG_NOT_CONNECTED }));
466
+ return yield* Effect.fail(makeNotConnectedError());
405
467
  }
406
- const id = `rpc-${++this.requestCounter}`;
407
- const frame = {
408
- jsonrpc: "2.0",
409
- type: "request",
410
- direction: "c2s",
411
- id,
412
- method,
413
- params,
414
- };
468
+ const id = jsonRpcStringId(`rpc-${++this.requestCounter}`);
469
+ const frame = requestFrame(id, definition, params);
415
470
  // Register the Deferred BEFORE writing. `write` yields to the
416
471
  // scheduler; the reader could interleave, see a close, and
417
472
  // `failAllPending` before we register — leaving us to await a
@@ -427,10 +482,16 @@ export class MoltZapWsClient {
427
482
  const writeAttempt = Effect.either(state.value.write(JSON.stringify(frame)));
428
483
  const earlyFailure = Deferred.await(deferred).pipe(Effect.catchAll(() => Effect.succeed(null)), Effect.as(null));
429
484
  const writeRace = yield* Effect.race(writeAttempt, earlyFailure);
430
- if (writeRace !== null && writeRace._tag === "Left") {
431
- this.options.logger?.warn("ws.send failed", writeRace.left);
485
+ const writeFailure = writeRace === null
486
+ ? null
487
+ : Either.match(writeRace, {
488
+ onLeft: (err) => err,
489
+ onRight: () => null,
490
+ });
491
+ if (writeFailure !== null) {
492
+ this.options.logger?.warn("ws.send failed", writeFailure);
432
493
  yield* Ref.update(this.pendingRef, (m) => HashMap.remove(m, id));
433
- return yield* Effect.fail(new NotConnectedError({ message: MSG_NOT_CONNECTED }));
494
+ return yield* Effect.fail(makeNotConnectedError());
434
495
  }
435
496
  const timeoutMs = opts?.timeoutMs ?? RPC_TIMEOUT_MS;
436
497
  const result = yield* Deferred.await(deferred).pipe(Effect.timeoutFail({
@@ -439,14 +500,12 @@ export class MoltZapWsClient {
439
500
  }), Effect.onExit((exit) => Exit.isFailure(exit)
440
501
  ? Ref.update(this.pendingRef, (m) => HashMap.remove(m, id))
441
502
  : Effect.void));
442
- // `type: "response"` is surfaced as an observable literal (V5):
443
- // the value the caller sees comes from the same response
444
- // envelope the reader fiber decoded, not a synthesized adapter
445
- // constant. The Frame schema at
446
- // `packages/protocol/src/schema/frames.ts:18` pins `type` to
447
- // that literal at decode time, so this assignment is the
448
- // value-level projection of that schema constraint.
449
- return { id, type: "response", result };
503
+ const decodedResult = yield* decodeRpcResult(definition, result).pipe(Effect.mapError(() => new RpcServerError({
504
+ code: JSON_RPC_INTERNAL_ERROR_CODE,
505
+ message: `Invalid result for method: ${definition.name}`,
506
+ data: result,
507
+ })));
508
+ return { id, result: decodedResult };
450
509
  });
451
510
  }
452
511
  /**
@@ -457,8 +516,8 @@ export class MoltZapWsClient {
457
516
  * Invariant 5 (typed error channel), and the `socket.writer` latch
458
517
  * race described below.
459
518
  */
460
- sendRpcEffect(method, params, opts) {
461
- return this.sendRpcTrackedEffect(method, params, opts).pipe(Effect.map((tracked) => tracked.result));
519
+ sendRpcEffect(definition, params, opts) {
520
+ return this.sendRpcTrackedEffect(definition, params, opts).pipe(Effect.map((tracked) => tracked.result));
462
521
  }
463
522
  /**
464
523
  * Translate a typed `OfferRejected` failure from the partitioned
@@ -470,39 +529,31 @@ export class MoltZapWsClient {
470
529
  * new tag fails the compile here.
471
530
  */
472
531
  writeOfferRejection(err, requestId, write) {
473
- const reply = (() => {
474
- switch (err._tag) {
475
- case "MalformedPartitionKeyError":
476
- return responseFrame("s2c", requestId, {
477
- error: {
478
- code: -32602,
479
- message: `Invalid params: ${err.reason}`,
480
- },
481
- });
482
- case "PartitionLimitError":
483
- return responseFrame("s2c", requestId, {
484
- error: {
485
- code: -32000,
486
- message: `Server busy: partition limit reached (${err.activePartitions}/${err.maxPartitions})`,
487
- },
488
- });
489
- case "PartitionQueueFullError":
490
- return responseFrame("s2c", requestId, {
491
- error: {
492
- code: -32000,
493
- message: `Server busy: partition queue full (capacity=${err.capacity})`,
494
- },
495
- });
496
- default: {
497
- const _exhaustive = err;
498
- throw new Error(`writeOfferRejection: unhandled OfferRejected tag: ${String(_exhaustive)}`);
499
- }
500
- }
501
- })();
502
- return write(JSON.stringify(reply)).pipe(Effect.catchAll((werr) => Effect.sync(() => this.options.logger?.warn("s2c offer-rejection write failed", werr))));
532
+ const reply = this.offerRejectedResponse(err, requestId);
533
+ return write(JSON.stringify(reply)).pipe(Effect.catchAll((werr) => Effect.sync(() => this.options.logger?.warn("appCallback offer-rejection write failed", werr))));
534
+ }
535
+ offerRejectedResponse(err, requestId) {
536
+ if (err instanceof PartitionLimitError) {
537
+ return responseFrame(requestId, {
538
+ error: {
539
+ code: -32000,
540
+ message: `Server busy: partition limit reached (${err.activePartitions}/${err.maxPartitions})`,
541
+ },
542
+ });
543
+ }
544
+ if (err instanceof PartitionQueueFullError) {
545
+ return responseFrame(requestId, {
546
+ error: {
547
+ code: -32000,
548
+ message: `Server busy: partition queue full (capacity=${err.capacity})`,
549
+ },
550
+ });
551
+ }
552
+ const _exhaustive = err;
553
+ return _exhaustive;
503
554
  }
504
555
  /**
505
- * Dispatch one inbound s2c request to the registered handler, encode
556
+ * Dispatch one inbound appCallback request to the registered handler, encode
506
557
  * the response, and write it back to the server. Errors are projected
507
558
  * onto an error response so the server's `Deferred.await` always
508
559
  * settles deterministically — never hangs on a missing or crashing
@@ -518,26 +569,25 @@ export class MoltZapWsClient {
518
569
  */
519
570
  dispatchInboundServerRequest(request, write) {
520
571
  return Effect.gen(this, function* () {
521
- const handlers = yield* Ref.get(this.s2cHandlersRef);
522
- const lookup = HashMap.get(handlers, request.method);
572
+ const handlers = yield* Ref.get(this.appCallbackHandlersRef);
573
+ const lookup = HashMap.get(handlers, request.definition);
523
574
  const buildReply = lookup._tag === "None"
524
- ? Effect.succeed(responseFrame("s2c", request.id, {
575
+ ? Effect.succeed(responseFrame(request.id, {
525
576
  error: {
526
577
  code: -32601,
527
- message: `No handler registered for method: ${request.method}`,
578
+ message: "No handler registered for app callback descriptor",
528
579
  },
529
580
  }))
530
581
  : lookup
531
582
  .value(request.params, {
532
583
  requestId: request.id,
533
- method: request.method,
534
- traceparent: request.traceparent,
584
+ definition: request.definition,
535
585
  })
536
586
  .pipe(Effect.match({
537
- onSuccess: (result) => responseFrame("s2c", request.id, {
587
+ onSuccess: (result) => responseFrame(request.id, {
538
588
  result,
539
589
  }),
540
- onFailure: (err) => responseFrame("s2c", request.id, {
590
+ onFailure: (err) => responseFrame(request.id, {
541
591
  error: {
542
592
  code: err.code,
543
593
  message: err.message,
@@ -545,23 +595,23 @@ export class MoltZapWsClient {
545
595
  },
546
596
  }),
547
597
  }), Effect.catchAllCause((cause) => Effect.sync(() => {
548
- this.options.logger?.warn(`s2c handler ${request.method} defected`, Cause.pretty(cause));
549
- return responseFrame("s2c", request.id, {
598
+ this.options.logger?.warn("appCallback handler defected", Cause.pretty(cause));
599
+ return responseFrame(request.id, {
550
600
  error: { code: -32603, message: "Internal error" },
551
601
  });
552
602
  })));
553
603
  const reply = yield* buildReply;
554
- yield* write(JSON.stringify(reply)).pipe(Effect.catchAll((err) => Effect.sync(() => this.options.logger?.warn("s2c response write failed", err))));
604
+ yield* write(JSON.stringify(reply)).pipe(Effect.catchAll((err) => Effect.sync(() => this.options.logger?.warn("appCallback response write failed", err))));
555
605
  });
556
606
  }
557
- /** Route an inbound frame. Malformed frames are logged + dropped; event
558
- * frames dispatch to `onEvent` after the shape check. */
607
+ /** Route an inbound frame. Malformed frames are logged + dropped; notification
608
+ * frames dispatch to `onNotification` after the shape check. */
559
609
  handleIncoming(raw) {
560
610
  return Effect.gen(this, function* () {
561
611
  const decodedFrames = yield* decodeFrames(raw).pipe(Effect.catchTag("MalformedFrameError", (err) => Effect.gen(this, function* () {
562
612
  const n = yield* Ref.updateAndGet(this.malformedRef, (c) => c + 1);
563
613
  if (n === 1 || n % MALFORMED_LOG_EVERY === 0) {
564
- this.options.logger?.warn(`Malformed frame (#${n}):`, err.raw.slice(0, 200));
614
+ this.options.logger?.warn(`Malformed frame (#${n}):`, err.raw.slice(0, MALFORMED_FRAME_PREVIEW_CHARS));
565
615
  }
566
616
  return null;
567
617
  })));
@@ -580,7 +630,9 @@ export class MoltZapWsClient {
580
630
  continue;
581
631
  if (error) {
582
632
  yield* Deferred.fail(pending, new RpcServerError({
583
- code: typeof error.code === "number" ? error.code : -32603,
633
+ code: typeof error.code === "number"
634
+ ? error.code
635
+ : JSON_RPC_INTERNAL_ERROR_CODE,
584
636
  message: error.message ?? MSG_RPC_ERROR_FALLBACK,
585
637
  data: error.data,
586
638
  }));
@@ -591,7 +643,7 @@ export class MoltZapWsClient {
591
643
  continue;
592
644
  }
593
645
  if (decoded._tag === "ServerRequest") {
594
- // s2c request — hand off to the partitioned dispatcher
646
+ // appCallback request — hand off to the partitioned dispatcher
595
647
  // (spec #356). The dispatcher routes by
596
648
  // `(sessionId, conversationId, hookKind)`; the matching
597
649
  // per-tuple worker fiber drains and runs
@@ -603,8 +655,7 @@ export class MoltZapWsClient {
603
655
  // `dispatcher.offer` is non-blocking: every failure mode is
604
656
  // tagged in `OfferRejected` and translated below to a wire
605
657
  // error response so the server's `Deferred.await` always
606
- // settles (no hangs on a malformed-key or partition-full
607
- // request).
658
+ // settles (no hangs on partition-full requests).
608
659
  const state = yield* Ref.get(this.stateRef);
609
660
  if (Option.isNone(state)) {
610
661
  // Reader fiber observed a frame without a corresponding
@@ -614,24 +665,24 @@ export class MoltZapWsClient {
614
665
  // gone too.
615
666
  continue;
616
667
  }
617
- const offered = yield* Effect.either(state.value.dispatcher.offer({
618
- id: decoded.id,
619
- method: decoded.method,
620
- params: decoded.params,
621
- }));
622
- if (offered._tag === "Left") {
623
- yield* this.writeOfferRejection(offered.left, decoded.id, state.value.write);
668
+ const offered = yield* Effect.either(state.value.dispatcher.offer(decoded));
669
+ const offerFailure = Either.match(offered, {
670
+ onLeft: (err) => err,
671
+ onRight: () => null,
672
+ });
673
+ if (offerFailure !== null) {
674
+ yield* this.writeOfferRejection(offerFailure, decoded.id, state.value.write);
624
675
  }
625
676
  continue;
626
677
  }
627
- if (decoded._tag === "Event") {
678
+ if (decoded._tag === "Notification") {
628
679
  // Spec #222 §5.3 (C4 + subscribe-stub): per-subscription
629
- // fan-out replaces the deleted top-level `onEvent` callback.
680
+ // fan-out replaces the deleted top-level `onNotification` callback.
630
681
  // Snapshot-at-dispatch semantics live inside the registry
631
682
  // (OQ-3 A); see runtime/subscribers.ts.
632
- yield* this.subscribers.dispatch(decoded.frame);
633
- const delivered = yield* Ref.modify(this.eventWaitersRef, (m) => {
634
- const bucket = HashMap.get(m, decoded.frame.event);
683
+ yield* this.subscribers.dispatch(decoded);
684
+ const delivered = yield* Ref.modify(this.notificationWaitersRef, (m) => {
685
+ const bucket = HashMap.get(m, decoded.definition);
635
686
  if (bucket._tag === "None" || bucket.value.length === 0) {
636
687
  return [null, m];
637
688
  }
@@ -639,16 +690,16 @@ export class MoltZapWsClient {
639
690
  const chosen = arr[arr.length - 1];
640
691
  const rest = arr.slice(0, -1);
641
692
  const nextMap = rest.length === 0
642
- ? HashMap.remove(m, decoded.frame.event)
643
- : HashMap.set(m, decoded.frame.event, rest);
693
+ ? HashMap.remove(m, decoded.definition)
694
+ : HashMap.set(m, decoded.definition, rest);
644
695
  return [chosen, nextMap];
645
696
  });
646
697
  if (delivered !== null) {
647
- yield* Deferred.succeed(delivered.deferred, decoded.frame).pipe(Effect.ignore);
698
+ yield* delivered.complete(decoded);
648
699
  continue;
649
700
  }
650
- yield* Ref.update(this.eventsBufferRef, (xs) => {
651
- const appended = [...xs, decoded.frame];
701
+ yield* Ref.update(this.notificationsBufferRef, (xs) => {
702
+ const appended = [...xs, decoded];
652
703
  return appended.length > MAX_EVENT_BUFFER
653
704
  ? appended.slice(-MAX_EVENT_BUFFER)
654
705
  : appended;
@@ -657,13 +708,13 @@ export class MoltZapWsClient {
657
708
  }
658
709
  });
659
710
  }
660
- /** Fail every outstanding event waiter with `message` and clear the map. */
661
- failAllEventWaiters(message) {
711
+ /** Fail every outstanding notification waiter with `message` and clear the map. */
712
+ failAllNotificationWaiters(message) {
662
713
  return Effect.gen(this, function* () {
663
- const waiters = yield* Ref.getAndSet(this.eventWaitersRef, HashMap.empty());
714
+ const waiters = yield* Ref.getAndSet(this.notificationWaitersRef, HashMap.empty());
664
715
  for (const [, bucket] of HashMap.entries(waiters)) {
665
716
  for (const w of bucket) {
666
- yield* Deferred.fail(w.deferred, new Error(message)).pipe(Effect.ignore);
717
+ yield* w.fail(new Error(message));
667
718
  }
668
719
  }
669
720
  });
@@ -690,8 +741,10 @@ export class MoltZapWsClient {
690
741
  }
691
742
  })),
692
743
  // Collapse typed errors so `Schedule.exponential` can retry.
693
- Effect.mapError(() => new Error("reconnect attempt failed")));
694
- const backoff = Schedule.exponential(Duration.millis(BASE_RECONNECT_DELAY_MS), 2).pipe(Schedule.either(Schedule.spaced(Duration.millis(MAX_RECONNECT_DELAY_MS))), Schedule.jittered);
744
+ Effect.mapError(() => new ReconnectAttemptFailedError({
745
+ reason: "reconnect attempt failed",
746
+ })));
747
+ 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);
695
748
  const loop = attempt.pipe(Effect.retry(backoff), Effect.asVoid, Effect.catchAll(() => Effect.void), Effect.ensuring(Effect.sync(() => {
696
749
  this.reconnectFiber = null;
697
750
  })), Effect.provide(NodeSocket.layerWebSocketConstructor));