@moltzap/client 2026.511.0 → 2026.518.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.
- package/dist/auth.d.ts +12 -6
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +67 -29
- package/dist/auth.js.map +1 -1
- package/dist/auth.test.js +113 -76
- package/dist/auth.test.js.map +1 -1
- package/dist/channel-core-context.test.d.ts +2 -0
- package/dist/channel-core-context.test.d.ts.map +1 -0
- package/dist/channel-core-context.test.js +334 -0
- package/dist/channel-core-context.test.js.map +1 -0
- package/dist/channel-core-dispatch.test.d.ts +2 -0
- package/dist/channel-core-dispatch.test.d.ts.map +1 -0
- package/dist/channel-core-dispatch.test.js +550 -0
- package/dist/channel-core-dispatch.test.js.map +1 -0
- package/dist/channel-core-enrichment.d.ts +8 -0
- package/dist/channel-core-enrichment.d.ts.map +1 -0
- package/dist/channel-core-enrichment.js +134 -0
- package/dist/channel-core-enrichment.js.map +1 -0
- package/dist/channel-core-errors.d.ts +20 -0
- package/dist/channel-core-errors.d.ts.map +1 -0
- package/dist/channel-core-errors.js +12 -0
- package/dist/channel-core-errors.js.map +1 -0
- package/dist/channel-core-test-support.d.ts +48 -0
- package/dist/channel-core-test-support.d.ts.map +1 -0
- package/dist/channel-core-test-support.js +111 -0
- package/dist/channel-core-test-support.js.map +1 -0
- package/dist/channel-core.d.ts +30 -7
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +214 -237
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +216 -1082
- package/dist/channel-core.test.js.map +1 -1
- package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
- package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
- package/dist/cli/commands/conversations-v2.test.js +64 -54
- package/dist/cli/commands/conversations-v2.test.js.map +1 -1
- package/dist/cli/commands/conversations.d.ts +5 -5
- package/dist/cli/commands/conversations.d.ts.map +1 -1
- package/dist/cli/commands/conversations.js +43 -36
- package/dist/cli/commands/conversations.js.map +1 -1
- package/dist/cli/commands/messages.d.ts +4 -4
- package/dist/cli/commands/messages.d.ts.map +1 -1
- package/dist/cli/commands/messages.js +4 -4
- package/dist/cli/commands/messages.js.map +1 -1
- package/dist/cli/commands/messages.test.js +61 -49
- package/dist/cli/commands/messages.test.js.map +1 -1
- package/dist/cli/commands/ping.d.ts +0 -9
- package/dist/cli/commands/ping.d.ts.map +1 -1
- package/dist/cli/commands/ping.js +1 -1
- package/dist/cli/commands/ping.js.map +1 -1
- package/dist/cli/commands/presence.d.ts +1 -1
- package/dist/cli/commands/presence.js +1 -1
- package/dist/cli/commands/register.d.ts +2 -2
- package/dist/cli/commands/register.d.ts.map +1 -1
- package/dist/cli/commands/register.js +98 -77
- package/dist/cli/commands/register.js.map +1 -1
- package/dist/cli/commands/register.test.js +63 -39
- package/dist/cli/commands/register.test.js.map +1 -1
- package/dist/cli/commands/send.d.ts +6 -6
- package/dist/cli/commands/send.js +6 -6
- package/dist/cli/commands/send.test.js +32 -28
- package/dist/cli/commands/send.test.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +3 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/config.d.ts +0 -2
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +86 -76
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/config.test.js +80 -42
- package/dist/cli/config.test.js.map +1 -1
- package/dist/cli/index.d.ts +1 -16
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +50 -113
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/process-argv.d.ts +2 -0
- package/dist/cli/process-argv.d.ts.map +1 -0
- package/dist/cli/process-argv.js +2 -0
- package/dist/cli/process-argv.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -10
- package/dist/cli/profile.d.ts.map +1 -1
- package/dist/cli/profile.js +105 -104
- package/dist/cli/profile.js.map +1 -1
- package/dist/cli/profile.test.js +338 -232
- package/dist/cli/profile.test.js.map +1 -1
- package/dist/cli/runtime.d.ts +5 -17
- package/dist/cli/runtime.d.ts.map +1 -1
- package/dist/cli/runtime.js +6 -59
- package/dist/cli/runtime.js.map +1 -1
- package/dist/cli/socket-client.d.ts +10 -15
- package/dist/cli/socket-client.d.ts.map +1 -1
- package/dist/cli/socket-client.js +74 -89
- package/dist/cli/socket-client.js.map +1 -1
- package/dist/cli/transport.d.ts +9 -5
- package/dist/cli/transport.d.ts.map +1 -1
- package/dist/cli/transport.js +45 -42
- package/dist/cli/transport.js.map +1 -1
- package/dist/cli/transport.test.js +227 -133
- package/dist/cli/transport.test.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/local-daemon-rpc.d.ts +13 -0
- package/dist/local-daemon-rpc.d.ts.map +1 -0
- package/dist/local-daemon-rpc.js +17 -0
- package/dist/local-daemon-rpc.js.map +1 -0
- package/dist/local-paths.d.ts +7 -0
- package/dist/local-paths.d.ts.map +1 -0
- package/dist/local-paths.js +20 -0
- package/dist/local-paths.js.map +1 -0
- package/dist/runtime/close-info.d.ts +3 -12
- package/dist/runtime/close-info.d.ts.map +1 -1
- package/dist/runtime/close-info.js +28 -39
- package/dist/runtime/close-info.js.map +1 -1
- package/dist/runtime/close-info.test.js +77 -70
- package/dist/runtime/close-info.test.js.map +1 -1
- package/dist/runtime/errors.test.js +42 -28
- package/dist/runtime/errors.test.js.map +1 -1
- package/dist/runtime/frame.d.ts +4 -31
- package/dist/runtime/frame.d.ts.map +1 -1
- package/dist/runtime/frame.js +7 -116
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/frame.test.js +41 -54
- package/dist/runtime/frame.test.js.map +1 -1
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +7 -11
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/local-history.d.ts +67 -0
- package/dist/runtime/local-history.d.ts.map +1 -0
- package/dist/runtime/local-history.js +77 -0
- package/dist/runtime/local-history.js.map +1 -0
- package/dist/runtime/local-service-commands.d.ts +19 -0
- package/dist/runtime/local-service-commands.d.ts.map +1 -1
- package/dist/runtime/local-service-commands.js +23 -0
- package/dist/runtime/local-service-commands.js.map +1 -1
- package/dist/runtime/local-socket-server.d.ts +20 -0
- package/dist/runtime/local-socket-server.d.ts.map +1 -0
- package/dist/runtime/local-socket-server.js +94 -0
- package/dist/runtime/local-socket-server.js.map +1 -0
- package/dist/runtime/refs.d.ts +1 -1
- package/dist/runtime/refs.js +1 -1
- package/dist/runtime/runtime.test.js +29 -21
- package/dist/runtime/runtime.test.js.map +1 -1
- package/dist/runtime/service-helpers.d.ts +32 -0
- package/dist/runtime/service-helpers.d.ts.map +1 -0
- package/dist/runtime/service-helpers.js +85 -0
- package/dist/runtime/service-helpers.js.map +1 -0
- package/dist/runtime/subscribers.d.ts +13 -17
- package/dist/runtime/subscribers.d.ts.map +1 -1
- package/dist/runtime/subscribers.js +37 -32
- package/dist/runtime/subscribers.js.map +1 -1
- package/dist/runtime/subscribers.test.js +139 -131
- package/dist/runtime/subscribers.test.js.map +1 -1
- package/dist/service-socket-path.test.d.ts +2 -0
- package/dist/service-socket-path.test.d.ts.map +1 -0
- package/dist/service-socket-path.test.js +87 -0
- package/dist/service-socket-path.test.js.map +1 -0
- package/dist/service.d.ts +46 -20
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +243 -340
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +650 -571
- package/dist/service.test.js.map +1 -1
- package/dist/test/index.d.ts +30 -14
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +25 -21
- package/dist/test/index.js.map +1 -1
- package/dist/test-utils/channel-service-fixture.d.ts +1 -0
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +139 -98
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
- package/dist/test-utils/conformance-adapter.js +145 -124
- package/dist/test-utils/conformance-adapter.js.map +1 -1
- package/dist/test-utils/fake-service.d.ts.map +1 -1
- package/dist/test-utils/fake-service.js +3 -3
- package/dist/test-utils/fake-service.js.map +1 -1
- package/dist/test-utils/index.d.ts +25 -0
- package/dist/test-utils/index.d.ts.map +1 -1
- package/dist/test-utils/index.js +25 -8
- package/dist/test-utils/index.js.map +1 -1
- package/dist/ws-client-test-support.d.ts +180 -0
- package/dist/ws-client-test-support.d.ts.map +1 -0
- package/dist/ws-client-test-support.js +399 -0
- package/dist/ws-client-test-support.js.map +1 -0
- package/dist/ws-client.d.ts +59 -32
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +272 -321
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.js +388 -800
- package/dist/ws-client.test.js.map +1 -1
- package/package.json +14 -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 {
|
|
4
|
-
import { PROTOCOL_VERSION, Connect, encodeErrorResponse,
|
|
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
|
-
/**
|
|
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
|
|
126
|
+
* `ConnState`. Returns `Effect<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
|
-
/**
|
|
159
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
247
|
+
this.runtime.dispose();
|
|
254
248
|
})));
|
|
255
249
|
}
|
|
256
|
-
/**
|
|
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*
|
|
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
|
-
|
|
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
|
|
289
|
-
?
|
|
282
|
+
}), Effect.onExit((exit) => Exit.isFailure(exit)
|
|
283
|
+
? this.removeNotificationWaiter(definition, waiter)
|
|
290
284
|
: Effect.void));
|
|
291
285
|
});
|
|
292
286
|
}
|
|
293
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
})
|
|
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.
|
|
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
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
/**
|
|
659
|
-
*
|
|
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.
|
|
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
|
-
|
|
618
|
+
yield* Effect.logWarning("onReconnect handler threw", err);
|
|
669
619
|
}
|
|
670
|
-
})),
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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(() => {
|