@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.
- package/dist/auth.d.ts +10 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +8 -5
- package/dist/auth.js.map +1 -1
- package/dist/channel-core.d.ts +1 -4
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +0 -9
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +90 -71
- package/dist/channel-core.test.js.map +1 -1
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +5 -3
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/apps.d.ts +9 -27
- package/dist/cli/commands/apps.d.ts.map +1 -1
- package/dist/cli/commands/apps.js +36 -57
- package/dist/cli/commands/apps.js.map +1 -1
- package/dist/cli/commands/apps.test.js +63 -75
- package/dist/cli/commands/apps.test.js.map +1 -1
- package/dist/cli/commands/contacts.d.ts +1 -5
- package/dist/cli/commands/contacts.d.ts.map +1 -1
- package/dist/cli/commands/contacts.js +14 -27
- package/dist/cli/commands/contacts.js.map +1 -1
- package/dist/cli/commands/conversations-v2.test.js +34 -29
- package/dist/cli/commands/conversations-v2.test.js.map +1 -1
- package/dist/cli/commands/conversations.d.ts +7 -3
- package/dist/cli/commands/conversations.d.ts.map +1 -1
- package/dist/cli/commands/conversations.js +63 -48
- package/dist/cli/commands/conversations.js.map +1 -1
- package/dist/cli/commands/messages.d.ts +7 -3
- package/dist/cli/commands/messages.d.ts.map +1 -1
- package/dist/cli/commands/messages.js +13 -15
- package/dist/cli/commands/messages.js.map +1 -1
- package/dist/cli/commands/messages.test.js +30 -31
- package/dist/cli/commands/messages.test.js.map +1 -1
- package/dist/cli/commands/ping.d.ts +9 -0
- package/dist/cli/commands/ping.d.ts.map +1 -1
- package/dist/cli/commands/ping.js +17 -4
- package/dist/cli/commands/ping.js.map +1 -1
- package/dist/cli/commands/presence.d.ts.map +1 -1
- package/dist/cli/commands/presence.js +6 -4
- package/dist/cli/commands/presence.js.map +1 -1
- package/dist/cli/commands/register.d.ts.map +1 -1
- package/dist/cli/commands/register.js +2 -1
- package/dist/cli/commands/register.js.map +1 -1
- package/dist/cli/commands/send.d.ts.map +1 -1
- package/dist/cli/commands/send.js +22 -11
- package/dist/cli/commands/send.js.map +1 -1
- package/dist/cli/commands/send.test.js +12 -9
- package/dist/cli/commands/send.test.js.map +1 -1
- package/dist/cli/commands/status.js +2 -2
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/test-transport.d.ts +10 -0
- package/dist/cli/commands/test-transport.d.ts.map +1 -0
- package/dist/cli/commands/test-transport.js +29 -0
- package/dist/cli/commands/test-transport.js.map +1 -0
- package/dist/cli/commands/whoami.d.ts.map +1 -1
- package/dist/cli/commands/whoami.js +5 -1
- package/dist/cli/commands/whoami.js.map +1 -1
- package/dist/cli/config.d.ts +42 -6
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +91 -27
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/http-client.d.ts +11 -1
- package/dist/cli/http-client.d.ts.map +1 -1
- package/dist/cli/http-client.js +13 -4
- package/dist/cli/http-client.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/profile.d.ts +3 -4
- package/dist/cli/profile.d.ts.map +1 -1
- package/dist/cli/profile.js +42 -29
- package/dist/cli/profile.js.map +1 -1
- package/dist/cli/runtime.d.ts.map +1 -1
- package/dist/cli/runtime.js +9 -4
- package/dist/cli/runtime.js.map +1 -1
- package/dist/cli/socket-client.d.ts +29 -4
- package/dist/cli/socket-client.d.ts.map +1 -1
- package/dist/cli/socket-client.js +52 -21
- package/dist/cli/socket-client.js.map +1 -1
- package/dist/cli/transport.d.ts +6 -9
- package/dist/cli/transport.d.ts.map +1 -1
- package/dist/cli/transport.js +47 -37
- package/dist/cli/transport.js.map +1 -1
- package/dist/cli/transport.test.js +19 -16
- package/dist/cli/transport.test.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/__tests__/app-callback-partition-key.test.d.ts +2 -0
- package/dist/internal/__tests__/app-callback-partition-key.test.d.ts.map +1 -0
- package/dist/internal/__tests__/app-callback-partition-key.test.js +33 -0
- package/dist/internal/__tests__/app-callback-partition-key.test.js.map +1 -0
- package/dist/internal/__tests__/app-callback-partition-worker.test.d.ts +2 -0
- package/dist/internal/__tests__/app-callback-partition-worker.test.d.ts.map +1 -0
- package/dist/internal/__tests__/{s2c-partition-worker.test.js → app-callback-partition-worker.test.js} +6 -8
- package/dist/internal/__tests__/app-callback-partition-worker.test.js.map +1 -0
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts +2 -0
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts.map +1 -0
- package/dist/internal/__tests__/{s2c-partitioned-dispatcher-real-ws.test.js → app-callback-partitioned-dispatcher-real-ws.test.js} +69 -93
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.js.map +1 -0
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.d.ts +2 -0
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.d.ts.map +1 -0
- package/dist/internal/__tests__/{s2c-partitioned-dispatcher.test.js → app-callback-partitioned-dispatcher.test.js} +36 -69
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.js.map +1 -0
- package/dist/internal/__tests__/app-callback-test-requests.d.ts +58 -0
- package/dist/internal/__tests__/app-callback-test-requests.d.ts.map +1 -0
- package/dist/internal/__tests__/app-callback-test-requests.js +124 -0
- package/dist/internal/__tests__/app-callback-test-requests.js.map +1 -0
- package/dist/internal/{s2c-dispatcher-errors.d.ts → app-callback-dispatcher-errors.d.ts} +5 -21
- package/dist/internal/app-callback-dispatcher-errors.d.ts.map +1 -0
- package/dist/internal/{s2c-dispatcher-errors.js → app-callback-dispatcher-errors.js} +3 -13
- package/dist/internal/app-callback-dispatcher-errors.js.map +1 -0
- package/dist/internal/app-callback-partition-key.d.ts +68 -0
- package/dist/internal/app-callback-partition-key.d.ts.map +1 -0
- package/dist/internal/app-callback-partition-key.js +90 -0
- package/dist/internal/app-callback-partition-key.js.map +1 -0
- package/dist/internal/{s2c-partition-worker.d.ts → app-callback-partition-worker.d.ts} +5 -5
- package/dist/internal/app-callback-partition-worker.d.ts.map +1 -0
- package/dist/internal/{s2c-partition-worker.js → app-callback-partition-worker.js} +5 -5
- package/dist/internal/app-callback-partition-worker.js.map +1 -0
- package/dist/internal/{s2c-partitioned-dispatcher.d.ts → app-callback-partitioned-dispatcher.d.ts} +9 -10
- package/dist/internal/app-callback-partitioned-dispatcher.d.ts.map +1 -0
- package/dist/internal/{s2c-partitioned-dispatcher.js → app-callback-partitioned-dispatcher.js} +13 -21
- package/dist/internal/app-callback-partitioned-dispatcher.js.map +1 -0
- package/dist/runtime/close-info.d.ts +15 -16
- package/dist/runtime/close-info.d.ts.map +1 -1
- package/dist/runtime/close-info.js +12 -8
- package/dist/runtime/close-info.js.map +1 -1
- package/dist/runtime/errors.d.ts +2 -28
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/errors.js +0 -9
- package/dist/runtime/errors.js.map +1 -1
- package/dist/runtime/errors.test.js +4 -3
- package/dist/runtime/errors.test.js.map +1 -1
- package/dist/runtime/frame.d.ts +17 -18
- package/dist/runtime/frame.d.ts.map +1 -1
- package/dist/runtime/frame.js +86 -31
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/frame.test.js +15 -11
- package/dist/runtime/frame.test.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/local-service-commands.d.ts +7 -0
- package/dist/runtime/local-service-commands.d.ts.map +1 -0
- package/dist/runtime/local-service-commands.js +6 -0
- package/dist/runtime/local-service-commands.js.map +1 -0
- package/dist/runtime/runtime.test.js +3 -3
- package/dist/runtime/runtime.test.js.map +1 -1
- package/dist/runtime/subscribers.d.ts +37 -33
- package/dist/runtime/subscribers.d.ts.map +1 -1
- package/dist/runtime/subscribers.js +22 -21
- package/dist/runtime/subscribers.js.map +1 -1
- package/dist/runtime/subscribers.test.js +50 -31
- package/dist/runtime/subscribers.test.js.map +1 -1
- package/dist/service.d.ts +24 -39
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +190 -171
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +117 -175
- package/dist/service.test.js.map +1 -1
- package/dist/test/index.d.ts +9 -1
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +14 -3
- package/dist/test/index.js.map +1 -1
- package/dist/test-utils/channel-service-fixture.d.ts +1 -2
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +28 -20
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/conformance-adapter.d.ts +2 -2
- package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
- package/dist/test-utils/conformance-adapter.js +78 -54
- package/dist/test-utils/conformance-adapter.js.map +1 -1
- package/dist/test-utils/fake-service.d.ts +14 -39
- package/dist/test-utils/fake-service.d.ts.map +1 -1
- package/dist/test-utils/fake-service.js +33 -39
- package/dist/test-utils/fake-service.js.map +1 -1
- package/dist/test-utils/ids.d.ts +9 -0
- package/dist/test-utils/ids.d.ts.map +1 -0
- package/dist/test-utils/ids.js +52 -0
- package/dist/test-utils/ids.js.map +1 -0
- package/dist/test-utils/index.d.ts +10 -2
- package/dist/test-utils/index.d.ts.map +1 -1
- package/dist/test-utils/index.js +25 -9
- package/dist/test-utils/index.js.map +1 -1
- package/dist/ws-client.d.ts +59 -64
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +228 -175
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.js +190 -176
- package/dist/ws-client.test.js.map +1 -1
- package/package.json +3 -3
- package/dist/cli/commands/permissions.d.ts +0 -66
- package/dist/cli/commands/permissions.d.ts.map +0 -1
- package/dist/cli/commands/permissions.js +0 -101
- package/dist/cli/commands/permissions.js.map +0 -1
- package/dist/cli/commands/permissions.test.d.ts +0 -2
- package/dist/cli/commands/permissions.test.d.ts.map +0 -1
- package/dist/cli/commands/permissions.test.js +0 -126
- package/dist/cli/commands/permissions.test.js.map +0 -1
- package/dist/internal/__tests__/s2c-partition-key.test.d.ts +0 -2
- package/dist/internal/__tests__/s2c-partition-key.test.d.ts.map +0 -1
- package/dist/internal/__tests__/s2c-partition-key.test.js +0 -230
- package/dist/internal/__tests__/s2c-partition-key.test.js.map +0 -1
- package/dist/internal/__tests__/s2c-partition-worker.test.d.ts +0 -2
- package/dist/internal/__tests__/s2c-partition-worker.test.d.ts.map +0 -1
- package/dist/internal/__tests__/s2c-partition-worker.test.js.map +0 -1
- package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.d.ts +0 -2
- package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.d.ts.map +0 -1
- package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.js.map +0 -1
- package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.d.ts +0 -2
- package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.d.ts.map +0 -1
- package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.js.map +0 -1
- package/dist/internal/s2c-dispatcher-errors.d.ts.map +0 -1
- package/dist/internal/s2c-dispatcher-errors.js.map +0 -1
- package/dist/internal/s2c-partition-key.d.ts +0 -71
- package/dist/internal/s2c-partition-key.d.ts.map +0 -1
- package/dist/internal/s2c-partition-key.js +0 -140
- package/dist/internal/s2c-partition-key.js.map +0 -1
- package/dist/internal/s2c-partition-worker.d.ts.map +0 -1
- package/dist/internal/s2c-partition-worker.js.map +0 -1
- package/dist/internal/s2c-partitioned-dispatcher.d.ts.map +0 -1
- 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
|
|
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/
|
|
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
|
|
25
|
-
* `
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
40
|
-
: HashMap.set(m,
|
|
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
|
-
|
|
72
|
+
notificationsBufferRef;
|
|
60
73
|
/**
|
|
61
|
-
* Waiters keyed by
|
|
62
|
-
* pops the most recently registered waiter (the tail).
|
|
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
|
-
|
|
77
|
+
notificationWaitersRef;
|
|
67
78
|
runtime;
|
|
68
79
|
/**
|
|
69
|
-
* Per-subscription
|
|
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
|
|
90
|
+
* dispatcher fiber when an appCallback request frame arrives.
|
|
80
91
|
*/
|
|
81
|
-
|
|
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.
|
|
93
|
-
this.
|
|
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.
|
|
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
|
|
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(
|
|
126
|
+
handleServerRpc(definition, handler) {
|
|
116
127
|
return Effect.gen(this, function* () {
|
|
117
|
-
const swapped = yield* Ref.modify(this.
|
|
118
|
-
if (HashMap.has(m,
|
|
128
|
+
const swapped = yield* Ref.modify(this.appCallbackHandlersRef, (m) => {
|
|
129
|
+
if (HashMap.has(m, definition))
|
|
119
130
|
return [false, m];
|
|
120
|
-
return [
|
|
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(
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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` / `
|
|
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(
|
|
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*
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
265
|
+
waitForNotification(definition, timeoutMs = EVENT_WAIT_TIMEOUT_MS) {
|
|
218
266
|
return Effect.gen(this, function* () {
|
|
219
|
-
const buffered = yield* Ref.modify(this.
|
|
220
|
-
const idx
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 = {
|
|
231
|
-
|
|
232
|
-
|
|
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,
|
|
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
|
|
293
|
+
onTimeout: () => new Error(`Timeout waiting for notification: ${definition.name}`),
|
|
239
294
|
}), Effect.onExit((exit) => exit._tag === "Failure"
|
|
240
|
-
? Ref.update(this.
|
|
295
|
+
? Ref.update(this.notificationWaitersRef, (m) => removeWaiter(m, definition, waiter))
|
|
241
296
|
: Effect.void));
|
|
242
297
|
});
|
|
243
298
|
}
|
|
244
|
-
/** Return all buffered
|
|
245
|
-
|
|
246
|
-
const snapshot = this.runtime.runSync(Ref.get(this.
|
|
247
|
-
this.runtime.runSync(Ref.set(this.
|
|
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
|
|
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
|
|
291
|
-
// single `Stream.runForEach`-driven `
|
|
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.
|
|
313
|
-
? { config: this.options.
|
|
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,
|
|
333
|
-
// Clear connection state BEFORE the
|
|
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
|
|
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.
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
431
|
-
|
|
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(
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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(
|
|
461
|
-
return this.sendRpcTrackedEffect(
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
})
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
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.
|
|
522
|
-
const lookup = HashMap.get(handlers, request.
|
|
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(
|
|
575
|
+
? Effect.succeed(responseFrame(request.id, {
|
|
525
576
|
error: {
|
|
526
577
|
code: -32601,
|
|
527
|
-
message:
|
|
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
|
-
|
|
534
|
-
traceparent: request.traceparent,
|
|
584
|
+
definition: request.definition,
|
|
535
585
|
})
|
|
536
586
|
.pipe(Effect.match({
|
|
537
|
-
onSuccess: (result) => responseFrame(
|
|
587
|
+
onSuccess: (result) => responseFrame(request.id, {
|
|
538
588
|
result,
|
|
539
589
|
}),
|
|
540
|
-
onFailure: (err) => responseFrame(
|
|
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(
|
|
549
|
-
return responseFrame(
|
|
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("
|
|
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;
|
|
558
|
-
* frames dispatch to `
|
|
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,
|
|
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"
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
})
|
|
622
|
-
if (
|
|
623
|
-
yield* this.writeOfferRejection(
|
|
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 === "
|
|
678
|
+
if (decoded._tag === "Notification") {
|
|
628
679
|
// Spec #222 §5.3 (C4 + subscribe-stub): per-subscription
|
|
629
|
-
// fan-out replaces the deleted top-level `
|
|
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
|
|
633
|
-
const delivered = yield* Ref.modify(this.
|
|
634
|
-
const bucket = HashMap.get(m, decoded.
|
|
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.
|
|
643
|
-
: HashMap.set(m, decoded.
|
|
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*
|
|
698
|
+
yield* delivered.complete(decoded);
|
|
648
699
|
continue;
|
|
649
700
|
}
|
|
650
|
-
yield* Ref.update(this.
|
|
651
|
-
const appended = [...xs, decoded
|
|
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
|
|
661
|
-
|
|
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.
|
|
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*
|
|
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
|
|
694
|
-
|
|
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));
|