@mulmobridge/client 1.2.0 → 1.4.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/README.md CHANGED
@@ -108,6 +108,30 @@ One case is outside this: a server-initiated disconnect (`io server disconnect`)
108
108
  is the one reason socket.io does not retry, so no connection failure follows it.
109
109
  The chat-service never issues one, so there is nothing to recover from today.
110
110
 
111
+ ## How long `send()` waits
112
+
113
+ The server gives the agent **5 minutes** per turn, then replies with whatever
114
+ text has streamed so far — anything the agent produces after that is dropped.
115
+ `send()` waits one minute longer than that, so the server's reply always wins
116
+ over a client-side timeout.
117
+
118
+ To give long turns more time, set the limit in milliseconds on the bridge:
119
+
120
+ ```bash
121
+ BRIDGE_REPLY_TIMEOUT_MS=1800000 # every bridge: 30 minutes
122
+ DISCORD_BRIDGE_REPLY_TIMEOUT_MS=1800000 # this bridge only (wins over the shared form)
123
+ ```
124
+
125
+ It travels to the server in the handshake options, so the server and `send()`
126
+ always use the same value — nothing to keep in step by hand. A value that is not
127
+ a positive whole number is ignored with a warning (the default applies); one
128
+ past Node's timer ceiling (about 24.8 days) is clamped with a warning.
129
+
130
+ While a turn is running, the next message in the same chat waits for it, so a
131
+ longer limit can also mean a longer wait for the message after it. Upgrade the
132
+ bridge together with the server: an older client keeps its fixed 6-minute wait
133
+ and gives up before a longer server limit ends.
134
+
111
135
  ## Ecosystem
112
136
 
113
137
  Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package family.
@@ -0,0 +1,5 @@
1
+ import { type BridgeOptions } from "@mulmobridge/protocol";
2
+ /** How long `send()` waits for the ack. Read from the same option the handshake
3
+ * sends the server, so it always outlasts the server's reply limit and the
4
+ * server's timeout surfaces as a reply, not a client-side cancellation. */
5
+ export declare function resolveAckTimeoutMs(options: BridgeOptions, warn: (message: string) => void): number;
@@ -0,0 +1,10 @@
1
+ import { ackTimeoutMsFor, resolveReplyTimeoutMs } from "@mulmobridge/protocol";
2
+ /** How long `send()` waits for the ack. Read from the same option the handshake
3
+ * sends the server, so it always outlasts the server's reply limit and the
4
+ * server's timeout surfaces as a reply, not a client-side cancellation. */
5
+ export function resolveAckTimeoutMs(options, warn) {
6
+ const { replyTimeoutMs, warning } = resolveReplyTimeoutMs(options.replyTimeoutMs);
7
+ if (warning)
8
+ warn(`[bridge] ${warning}`);
9
+ return ackTimeoutMsFor(replyTimeoutMs);
10
+ }
package/dist/client.js CHANGED
@@ -13,13 +13,11 @@
13
13
  // minimal non-Node equivalent.
14
14
  import { io } from "socket.io-client";
15
15
  import { CHAT_SOCKET_EVENTS, CHAT_SOCKET_PATH } from "@mulmobridge/protocol";
16
+ import { resolveAckTimeoutMs } from "./ackTimeout.js";
16
17
  import { readBridgeToken, tokenFilePath } from "./token.js";
17
18
  import { readBridgeEnvOptions } from "./options.js";
18
19
  import { DEFAULT_API_URL, resolvePublishedApiUrl } from "./apiUrl.js";
19
20
  import { backoffMs, credentialsChanged } from "./supervisor.js";
20
- // 6 min > the server's REPLY_TIMEOUT_MS (5 min) so the server's
21
- // timeout surfaces as a reply, not a client-side cancellation.
22
- const REPLY_TIMEOUT_MS = 6 * 60 * 1000;
23
21
  /**
24
22
  * Resolve the bearer token from the workspace / env var, exit with
25
23
  * a clear error if absent. Kept separate so bridges that want to
@@ -69,7 +67,10 @@ export function createBridgeClient(opts) {
69
67
  const token = requireBearerToken();
70
68
  // `opts.options === undefined` → scrape env automatically.
71
69
  // `opts.options === {}` → opt out of the scrape explicitly.
72
- const options = opts.options ?? readBridgeEnvOptions(opts.transportId, process.env);
70
+ // A copy: every reconnect re-sends it, and the ack limit below is fixed from it
71
+ // once, so a caller mutating their object later must not move one without the other.
72
+ const options = { ...(opts.options ?? readBridgeEnvOptions(opts.transportId, process.env)) };
73
+ const ackTimeoutMs = resolveAckTimeoutMs(options, console.error);
73
74
  const subscriptions = emptySubscriptions();
74
75
  const pending = new Set();
75
76
  const published = resolvePublishedApiUrl(opts.apiUrl);
@@ -191,7 +192,7 @@ export function createBridgeClient(opts) {
191
192
  live.retry = setTimeout(reresolve, backoffMs(live.attempt));
192
193
  }
193
194
  return {
194
- send: (externalChatId, text, attachments) => sendMessage(live.socket, pending, externalChatId, text, attachments),
195
+ send: (externalChatId, text, attachments) => sendMessage({ socket: live.socket, pending, ackTimeoutMs }, externalChatId, text, attachments),
195
196
  onPush: (handler) => {
196
197
  subscriptions.push.push(handler);
197
198
  live.socket.on(CHAT_SOCKET_EVENTS.push, handler);
@@ -222,7 +223,8 @@ export function createBridgeClient(opts) {
222
223
  },
223
224
  };
224
225
  }
225
- function sendMessage(socket, pending, externalChatId, text, attachments) {
226
+ function sendMessage(channel, externalChatId, text, attachments) {
227
+ const { socket, pending, ackTimeoutMs } = channel;
226
228
  const payload = { externalChatId, text };
227
229
  if (attachments && attachments.length > 0)
228
230
  payload.attachments = attachments;
@@ -231,7 +233,7 @@ function sendMessage(socket, pending, externalChatId, text, attachments) {
231
233
  // CANCELLABLE. socket.io arms its ack timer at emit time and keeps it armed
232
234
  // on a socket that is closed underneath it, so a send abandoned by a rebuild
233
235
  // left a six-minute timer behind per send — measured: the test process exited
234
- // at 6:00.45, exactly REPLY_TIMEOUT_MS, long after every assertion had passed
236
+ // at 6:00.45, exactly the ack timeout, long after every assertion had passed
235
237
  // (Codex, #3078). `settle` clears it, so `abandon` clears it too.
236
238
  const state = {};
237
239
  const settle = (ack) => {
@@ -240,7 +242,7 @@ function sendMessage(socket, pending, externalChatId, text, attachments) {
240
242
  clearTimeout(state.timer);
241
243
  resolve(ack);
242
244
  };
243
- state.timer = setTimeout(() => settle({ ok: false, error: `timeout: no ack within ${REPLY_TIMEOUT_MS}ms` }), REPLY_TIMEOUT_MS);
245
+ state.timer = setTimeout(() => settle({ ok: false, error: `timeout: no ack within ${ackTimeoutMs}ms` }), ackTimeoutMs);
244
246
  pending.add(settle);
245
247
  socket.emit(CHAT_SOCKET_EVENTS.message, payload, (ack) => {
246
248
  settle(ack ?? { ok: false, error: "no ack from server" });
@@ -253,8 +255,8 @@ function sendMessage(socket, pending, externalChatId, text, attachments) {
253
255
  * socket.io settles an IN-FLIGHT ack immediately when its socket closes, but a
254
256
  * send issued while the socket was already disconnected is queued for a
255
257
  * reconnection that will never happen here — the socket is being replaced, not
256
- * reconnected — so its callback would sit for the full 6-minute ack timeout
257
- * (measured, Codex). The bridge's user would wait six minutes for a message the
258
+ * reconnected — so its callback would sit for the full ack timeout
259
+ * (measured, Codex). The bridge's user would wait that whole time for a message the
258
260
  * client already knows it cannot deliver.
259
261
  */
260
262
  function abandon(pending, reason) {
@@ -0,0 +1,34 @@
1
+ import type { Attachment, BridgeOptions } from "@mulmobridge/protocol";
2
+ import type { BridgeClient, PushEvent } from "./client.js";
3
+ /** Shape of `chat-service`'s `RelayResult`, restated so this package does not
4
+ * import the server package. Kept structural on purpose: the host passes its
5
+ * own function and TypeScript checks the two agree. */
6
+ export type InProcessRelayResult = {
7
+ kind: "ok";
8
+ reply: string;
9
+ } | {
10
+ kind: "error";
11
+ status: number;
12
+ message: string;
13
+ };
14
+ export type InProcessRelayFn = (params: {
15
+ transportId: string;
16
+ externalChatId: string;
17
+ text: string;
18
+ attachments?: Attachment[] | undefined;
19
+ bridgeOptions?: Readonly<Record<string, string | number | boolean>> | undefined;
20
+ onChunk?: ((text: string) => void) | undefined;
21
+ }) => Promise<InProcessRelayResult>;
22
+ /** Registers this bridge to receive server → bridge pushes. Returns the
23
+ * unregister function, which `close()` calls. */
24
+ export type RegisterInProcessPush = (transportId: string, handler: (event: PushEvent) => void) => () => void;
25
+ export interface InProcessBridgeClientOptions {
26
+ transportId: string;
27
+ relay: InProcessRelayFn;
28
+ registerPush: RegisterInProcessPush;
29
+ /** Forwarded to the host's `startChat` exactly as the handshake bag is on the
30
+ * socket path. Defaults to `{}` — an in-process bridge is configured by the
31
+ * host, so there is no env to scrape on its behalf. */
32
+ options?: BridgeOptions;
33
+ }
34
+ export declare function createInProcessBridgeClient(opts: InProcessBridgeClientOptions): BridgeClient;
@@ -0,0 +1,77 @@
1
+ // A `BridgeClient` that talks to a chat service living in the SAME process,
2
+ // with no socket, no port and no bearer token (#3080).
3
+ //
4
+ // `packages/chat-service/src/relay.ts` calls itself "the shared core of the
5
+ // bridge chat flow ... HTTP (router) and socket.io transports both call the
6
+ // `RelayFn` this factory returns". This is the third caller of that same core,
7
+ // so nothing new is introduced server-side — only a different way in.
8
+ //
9
+ // The relay and the push registration arrive as callbacks rather than imports:
10
+ // `@mulmobridge/client` sits BELOW the server in the dependency direction and
11
+ // must not import `@mulmoclaude/chat-service`.
12
+ export function createInProcessBridgeClient(opts) {
13
+ const bridgeOptions = opts.options ?? {};
14
+ const pushHandlers = [];
15
+ const chunkHandlers = [];
16
+ let unregisterPush = null;
17
+ let closed = false;
18
+ const deliverPush = (event) => {
19
+ for (const handler of pushHandlers) {
20
+ try {
21
+ handler(event);
22
+ }
23
+ catch (err) {
24
+ // Per subscriber, so one bad handler does not stop the ones after it
25
+ // from seeing the push — and does not reach the server's stack.
26
+ console.error(`[${opts.transportId}] push handler threw: ${err instanceof Error ? err.message : String(err)}`);
27
+ }
28
+ }
29
+ };
30
+ const send = async (externalChatId, text, attachments) => {
31
+ if (closed)
32
+ return { ok: false, error: "bridge client is closed" };
33
+ const result = await opts.relay({
34
+ transportId: opts.transportId,
35
+ externalChatId,
36
+ text,
37
+ attachments,
38
+ bridgeOptions,
39
+ // Only subscribe the relay to chunks when someone is listening, so the
40
+ // serialiser is not paying for a stream nobody reads.
41
+ onChunk: chunkHandlers.length > 0 ? (chunk) => chunkHandlers.forEach((handler) => handler(chunk)) : undefined,
42
+ });
43
+ return result.kind === "ok" ? { ok: true, reply: result.reply } : { ok: false, error: result.message, status: result.status };
44
+ };
45
+ return {
46
+ send,
47
+ onPush(handler) {
48
+ pushHandlers.push(handler);
49
+ // Registered on the FIRST subscriber so a bridge that never listens costs
50
+ // the host nothing, and only once however many handlers are added.
51
+ unregisterPush ??= opts.registerPush(opts.transportId, deliverPush);
52
+ },
53
+ onTextChunk(handler) {
54
+ chunkHandlers.push(handler);
55
+ },
56
+ // There is no socket, so there is no connection to gain or lose. An
57
+ // in-process bridge is connected from the moment it is constructed.
58
+ onConnect(handler) {
59
+ handler();
60
+ },
61
+ onDisconnect() { },
62
+ close() {
63
+ closed = true;
64
+ unregisterPush?.();
65
+ unregisterPush = null;
66
+ pushHandlers.length = 0;
67
+ chunkHandlers.length = 0;
68
+ },
69
+ get socket() {
70
+ // Deliberately loud rather than null. Measured across all 25 bridges: none
71
+ // reads `.socket`, so the first caller to do so is writing new code against
72
+ // an assumption that does not hold here, and a null would surface as an
73
+ // unrelated TypeError somewhere downstream.
74
+ throw new Error("in-process bridge client has no socket — see packages/client/src/inProcess.ts");
75
+ },
76
+ };
77
+ }
package/dist/index.d.ts CHANGED
@@ -8,3 +8,4 @@ export { asJsonRecord, fetchJsonRecord, type JsonRecord } from "./http.js";
8
8
  export { formatAckReply } from "./reply.js";
9
9
  export { mimeFromExtension, isImageMime, isPdfMime, isSupportedAttachmentMime, isNativeAttachmentMime, parseDataUrl, buildDataUrl, type ParsedDataUrl, } from "./mime.js";
10
10
  export { installProcessGuards, SHUTDOWN_GRACE_MS, type ProcessGuardOptions, type ShutdownTask } from "./processGuards.js";
11
+ export { createInProcessBridgeClient, type InProcessBridgeClientOptions, type InProcessRelayFn, type InProcessRelayResult, type RegisterInProcessPush, } from "./inProcess.js";
package/dist/index.js CHANGED
@@ -9,3 +9,4 @@ export { asJsonRecord, fetchJsonRecord } from "./http.js";
9
9
  export { formatAckReply } from "./reply.js";
10
10
  export { mimeFromExtension, isImageMime, isPdfMime, isSupportedAttachmentMime, isNativeAttachmentMime, parseDataUrl, buildDataUrl, } from "./mime.js";
11
11
  export { installProcessGuards, SHUTDOWN_GRACE_MS } from "./processGuards.js";
12
+ export { createInProcessBridgeClient, } from "./inProcess.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/client",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Socket.io client library for MulmoBridge — shared by all bridge implementations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,19 +38,19 @@
38
38
  "scripts": {
39
39
  "build": "tsc",
40
40
  "prepack": "yarn build",
41
- "typecheck": "tsc --noEmit",
41
+ "typecheck": "tsc -p tsconfig.typecheck.json",
42
42
  "test": "tsx --test test/test_*.ts",
43
43
  "lint": "eslint src test"
44
44
  },
45
45
  "license": "MIT",
46
46
  "author": "Receptron Team",
47
47
  "dependencies": {
48
- "@mulmobridge/protocol": "^1.0.1",
48
+ "@mulmobridge/protocol": "^1.1.0",
49
49
  "@mulmoclaude/common": "^1.3.0",
50
50
  "socket.io-client": "^4.0.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@types/node": "^26.4.1",
53
+ "@types/node": "^26.6.3",
54
54
  "typescript": "^6.0.3"
55
55
  },
56
56
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/client#readme",