@mulmobridge/client 1.1.0 → 1.3.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
@@ -60,7 +60,25 @@ instead`). Whatever it ends up binding, it publishes to `<workspace>/.server-por
60
60
  1. `opts.apiUrl` — an explicit value always wins
61
61
  2. `$MULMOCLAUDE_API_URL`
62
62
  3. `http://127.0.0.1:<port>` from `<workspace>/.server-port`
63
- 4. `http://localhost:3001`
63
+
64
+ The fourth step — `http://localhost:3001` — depends on **who supplied the
65
+ token**, and that is a security boundary rather than a quirk:
66
+
67
+ - **Token from the workspace** (`.session-token`): no fallback. The workspace
68
+ owns both halves, so a token without a port is HALF a generation — the server
69
+ is mid-startup and has not bound yet. The client waits and joins when the port
70
+ appears. The window is not narrow: the server writes `.session-token` before
71
+ it binds, with sandbox setup (a Docker image build on a cold start) in
72
+ between, so it can last minutes (#3078).
73
+ - **Token pinned by you** (`MULMOCLAUDE_AUTH_TOKEN`): the default still applies.
74
+ You supplied the credential and are pointing the bridge somewhere deliberately
75
+ — a container without the workspace mounted, say — so there is no freshly
76
+ minted secret to strand.
77
+
78
+ `resolveApiUrl()` still returns `http://localhost:3001` as its last step, and
79
+ `DEFAULT_API_URL` is still exported — they are for naming a default, not for
80
+ connecting to one. `resolvePublishedApiUrl()` is the same order WITHOUT that
81
+ step, and is what the client uses.
64
82
 
65
83
  The workspace itself is `$MULMOCLAUDE_WORKSPACE_PATH`, or `~/mulmoclaude` when
66
84
  that is unset — the same rule the server applies, and the same root the bearer
@@ -78,8 +96,17 @@ variable, or run the bridge from the directory holding the `.env`.
78
96
  | `readBridgeToken()` / `tokenFilePath()` | at call time |
79
97
  | `TOKEN_FILE_PATH` | at import time — a snapshot, kept for compatibility |
80
98
 
81
- The port is read once, when the client is created. A server that restarts onto a
82
- *different* port after that still needs the bridge restarted.
99
+ ### Following a restart
100
+
101
+ The pair is re-read whenever the connection fails. If the server comes back as a
102
+ different generation — a new token, a new port, or both — the client rebuilds its
103
+ socket against it and your handlers are re-attached; nothing needs restarting
104
+ (#3078). If the pair is unchanged, the socket is left alone so socket.io's own
105
+ reconnection handles an ordinary outage.
106
+
107
+ One case is outside this: a server-initiated disconnect (`io server disconnect`)
108
+ is the one reason socket.io does not retry, so no connection failure follows it.
109
+ The chat-service never issues one, so there is nothing to recover from today.
83
110
 
84
111
  ## Ecosystem
85
112
 
package/dist/apiUrl.d.ts CHANGED
@@ -20,6 +20,19 @@ export declare function parsePublishedPort(raw: string | null): number | null;
20
20
  * is the silent misdirection this module exists to remove (#2981).
21
21
  */
22
22
  export declare function readPublishedApiUrl(): string | null;
23
+ /**
24
+ * Everything except the default: an explicit argument, `MULMOCLAUDE_API_URL`,
25
+ * or the port the server published — and `null` when none of those says
26
+ * anything.
27
+ *
28
+ * The distinction matters exactly once, and it is a security boundary rather
29
+ * than a nicety. A caller RECONNECTING must be able to tell "the server has not
30
+ * published a port yet" from "here is a port", because the server clears
31
+ * `.server-port` at startup and writes the new token before publishing the new
32
+ * one (#3082). Collapsing the first into `DEFAULT_API_URL` there would take a
33
+ * freshly minted bearer token to whatever holds 3001 (Codex, #3078).
34
+ */
35
+ export declare function resolvePublishedApiUrl(explicit?: string): string | null;
23
36
  /**
24
37
  * Resolution order: explicit argument → `MULMOCLAUDE_API_URL` → the port the
25
38
  * server published → `DEFAULT_API_URL`.
@@ -28,5 +41,9 @@ export declare function readPublishedApiUrl(): string | null;
28
41
  * An EMPTY value falls through instead of being used verbatim, matching how
29
42
  * `readBridgeToken` treats an empty `MULMOCLAUDE_AUTH_TOKEN` — an empty
30
43
  * string reached `io("")` before this.
44
+ *
45
+ * The default at the end is a STARTUP affordance: a bridge run against a
46
+ * machine where no server has published anything still tries the conventional
47
+ * port. Do not reuse it for reconnection — see `resolvePublishedApiUrl`.
31
48
  */
32
49
  export declare function resolveApiUrl(explicit?: string): string;
package/dist/apiUrl.js CHANGED
@@ -12,9 +12,10 @@
12
12
  // (`wait-for-backend`); both were fixed by reading the published port.
13
13
  //
14
14
  // A leftover `.server-port` cannot mislead here the way it can mislead
15
- // `yarn dev`: the file is not removed on shutdown, but the server REWRITES it
16
- // on every startup, so a running server's entry is always current — and with
17
- // no server running, the old hardcoded 3001 was just as dead.
15
+ // `yarn dev`: the server REWRITES it on every startup, so a running server's
16
+ // entry is always current — and with no server running, the old hardcoded 3001
17
+ // was just as dead. Since #3082 a graceful shutdown removes it too, so the
18
+ // leftover case is now a crash rather than the ordinary stop.
18
19
  import { readSidecarFile, SIDECAR_FILES } from "./workspace.js";
19
20
  /** Used only when nothing has been published and nothing was configured. */
20
21
  export const DEFAULT_API_URL = "http://localhost:3001";
@@ -51,6 +52,26 @@ export function readPublishedApiUrl() {
51
52
  const port = parsePublishedPort(readSidecarFile(SIDECAR_FILES.port));
52
53
  return port === null ? null : `http://127.0.0.1:${port}`;
53
54
  }
55
+ /**
56
+ * Everything except the default: an explicit argument, `MULMOCLAUDE_API_URL`,
57
+ * or the port the server published — and `null` when none of those says
58
+ * anything.
59
+ *
60
+ * The distinction matters exactly once, and it is a security boundary rather
61
+ * than a nicety. A caller RECONNECTING must be able to tell "the server has not
62
+ * published a port yet" from "here is a port", because the server clears
63
+ * `.server-port` at startup and writes the new token before publishing the new
64
+ * one (#3082). Collapsing the first into `DEFAULT_API_URL` there would take a
65
+ * freshly minted bearer token to whatever holds 3001 (Codex, #3078).
66
+ */
67
+ export function resolvePublishedApiUrl(explicit) {
68
+ if (typeof explicit === "string" && explicit.length > 0)
69
+ return explicit;
70
+ const fromEnv = process.env.MULMOCLAUDE_API_URL;
71
+ if (typeof fromEnv === "string" && fromEnv.length > 0)
72
+ return fromEnv;
73
+ return readPublishedApiUrl();
74
+ }
54
75
  /**
55
76
  * Resolution order: explicit argument → `MULMOCLAUDE_API_URL` → the port the
56
77
  * server published → `DEFAULT_API_URL`.
@@ -59,12 +80,11 @@ export function readPublishedApiUrl() {
59
80
  * An EMPTY value falls through instead of being used verbatim, matching how
60
81
  * `readBridgeToken` treats an empty `MULMOCLAUDE_AUTH_TOKEN` — an empty
61
82
  * string reached `io("")` before this.
83
+ *
84
+ * The default at the end is a STARTUP affordance: a bridge run against a
85
+ * machine where no server has published anything still tries the conventional
86
+ * port. Do not reuse it for reconnection — see `resolvePublishedApiUrl`.
62
87
  */
63
88
  export function resolveApiUrl(explicit) {
64
- if (typeof explicit === "string" && explicit.length > 0)
65
- return explicit;
66
- const fromEnv = process.env.MULMOCLAUDE_API_URL;
67
- if (typeof fromEnv === "string" && fromEnv.length > 0)
68
- return fromEnv;
69
- return readPublishedApiUrl() ?? DEFAULT_API_URL;
89
+ return resolvePublishedApiUrl(explicit) ?? DEFAULT_API_URL;
70
90
  }
package/dist/client.d.ts CHANGED
@@ -43,8 +43,10 @@ export interface BridgeClient {
43
43
  onDisconnect(handler: (reason: string) => void): void;
44
44
  /** Explicit shutdown. */
45
45
  close(): void;
46
- /** Escape hatch — raw socket for anything the helpers don't cover. */
47
- socket: Socket;
46
+ /** Escape hatch — the socket in use NOW. Read it per use rather than
47
+ * caching it: the client replaces the socket when the server comes back
48
+ * as a different generation (#3078). */
49
+ readonly socket: Socket;
48
50
  }
49
51
  /**
50
52
  * Resolve the bearer token from the workspace / env var, exit with
package/dist/client.js CHANGED
@@ -15,7 +15,8 @@ import { io } from "socket.io-client";
15
15
  import { CHAT_SOCKET_EVENTS, CHAT_SOCKET_PATH } from "@mulmobridge/protocol";
16
16
  import { readBridgeToken, tokenFilePath } from "./token.js";
17
17
  import { readBridgeEnvOptions } from "./options.js";
18
- import { resolveApiUrl } from "./apiUrl.js";
18
+ import { DEFAULT_API_URL, resolvePublishedApiUrl } from "./apiUrl.js";
19
+ import { backoffMs, credentialsChanged } from "./supervisor.js";
19
20
  // 6 min > the server's REPLY_TIMEOUT_MS (5 min) so the server's
20
21
  // timeout surfaces as a reply, not a client-side cancellation.
21
22
  const REPLY_TIMEOUT_MS = 6 * 60 * 1000;
@@ -39,70 +40,227 @@ export function requireBearerToken() {
39
40
  `same value the server is using.\n`);
40
41
  return process.exit(1);
41
42
  }
43
+ const emptySubscriptions = () => ({ push: [], textChunk: [], connect: [], disconnect: [] });
44
+ /** The handshake bag. `options` is omitted when empty so a server too old to
45
+ * know the field never sees an empty object on the wire. */
46
+ function buildAuth(transportId, token, options) {
47
+ const auth = { transportId, token };
48
+ if (Object.keys(options).length > 0)
49
+ auth.options = options;
50
+ return auth;
51
+ }
52
+ function attach(socket, subscriptions) {
53
+ subscriptions.push.forEach((handler) => socket.on(CHAT_SOCKET_EVENTS.push, handler));
54
+ subscriptions.textChunk.forEach((handler) => socket.on(CHAT_SOCKET_EVENTS.textChunk, (event) => {
55
+ handler(event.text);
56
+ }));
57
+ subscriptions.connect.forEach((handler) => socket.on("connect", handler));
58
+ subscriptions.disconnect.forEach((handler) => socket.on("disconnect", handler));
59
+ }
42
60
  export function createBridgeClient(opts) {
43
61
  // Token BEFORE port. A restart rewrites both files and nothing marks them as
44
62
  // one generation, so a bridge starting mid-restart can read a torn pair in
45
63
  // either order. What the order decides is WHICH tear it gets. Port first
46
64
  // yields a NEW token with an OLD port — a fresh credential sent to the port
47
- // the server has just left, retried in silence because the socket's URL is
48
- // fixed at construction. Token first mostly yields the opposite, an OLD token
49
- // with a NEW port, which the right server answers `invalid token` and the
50
- // connect handler explains; the dangerous pairing survives only in the narrow
51
- // window where BOTH reads fall between the token write and the port publish.
52
- // Closing it needs a shared generation marker on the sidecars (Codex, #3082).
65
+ // the server has just left. Token first mostly yields the opposite, an OLD
66
+ // token with a NEW port, which the right server answers `invalid token`; the
67
+ // dangerous pairing survives only in the narrow window where BOTH reads fall
68
+ // between the token write and the port publish (Codex, #3082).
53
69
  const token = requireBearerToken();
54
- const apiUrl = resolveApiUrl(opts.apiUrl);
55
70
  // `opts.options === undefined` → scrape env automatically.
56
71
  // `opts.options === {}` → opt out of the scrape explicitly.
57
72
  const options = opts.options ?? readBridgeEnvOptions(opts.transportId, process.env);
58
- // Only include the `options` key in the handshake when there's
59
- // something to send — keeps old servers unaware of the field from
60
- // ever seeing an empty object on the wire.
61
- const auth = { transportId: opts.transportId, token };
62
- if (Object.keys(options).length > 0)
63
- auth.options = options;
64
- const socket = io(apiUrl, {
65
- path: CHAT_SOCKET_PATH,
66
- auth,
67
- transports: ["websocket"],
68
- });
69
- installDefaultLogging(socket);
73
+ const subscriptions = emptySubscriptions();
74
+ const pending = new Set();
75
+ const published = resolvePublishedApiUrl(opts.apiUrl);
76
+ // Who supplied the token decides whether the startup default is usable.
77
+ //
78
+ // From the WORKSPACE: the workspace is the source of truth for both halves, so
79
+ // a token without a port is half a generation — the server is mid-startup and
80
+ // has not bound yet. Connecting to the default there hands a freshly minted
81
+ // credential to whoever holds 3001, and that window is minutes wide on a cold
82
+ // start (#3078). Wait instead.
83
+ //
84
+ // From `MULMOCLAUDE_AUTH_TOKEN`: the caller pinned a credential themselves and
85
+ // is pointing this bridge somewhere deliberately — a container without the
86
+ // workspace mounted, a server too old to publish. There is no fresh secret to
87
+ // strand, and refusing the default would break a setup that worked (Codex,
88
+ // round-5 checkpoint). They keep the documented fallback.
89
+ const tokenIsPinned = typeof process.env.MULMOCLAUDE_AUTH_TOKEN === "string" && process.env.MULMOCLAUDE_AUTH_TOKEN.length > 0;
90
+ const startAddress = published ?? (tokenIsPinned ? DEFAULT_API_URL : null);
91
+ // `DEFAULT_API_URL` is a placeholder when we are waiting, never a destination:
92
+ // the idle socket is built with `autoConnect: false` and is replaced before it
93
+ // ever handshakes, so the token cannot reach it.
94
+ const startedAt = { apiUrl: startAddress ?? DEFAULT_API_URL, token };
95
+ /** The pair as the workspace has it NOW, or null while the server is mid-restart.
96
+ *
97
+ * BOTH halves have to be present. The startup default is deliberately not
98
+ * consulted here: the server clears `.server-port` before writing the new
99
+ * token (#3082), so "token, no port" is a real and frequent state, and
100
+ * resolving it to `http://localhost:3001` would carry a freshly minted
101
+ * bearer token to whatever holds that port (Codex, #3078). Half a generation
102
+ * is not a generation. */
103
+ const reread = () => {
104
+ const freshToken = readBridgeToken();
105
+ const freshApiUrl = resolvePublishedApiUrl(opts.apiUrl);
106
+ if (freshToken === null || freshApiUrl === null)
107
+ return null;
108
+ return { apiUrl: freshApiUrl, token: freshToken };
109
+ };
110
+ const open = (credentials) => {
111
+ // Say where we are going, every time, from the SHARED client — so the
112
+ // answer exists for all 25 bridges and not just the one that happened to
113
+ // print a banner. `error-recovery.md` leans on this line to separate "an
114
+ // old build hardcoding 3001" from "the address is right": a diagnostic the
115
+ // help describes has to be one the code actually emits (#3085).
116
+ console.error(`Connecting to ${credentials.apiUrl}`);
117
+ const socket = io(credentials.apiUrl, {
118
+ path: CHAT_SOCKET_PATH,
119
+ auth: buildAuth(opts.transportId, credentials.token, options),
120
+ transports: ["websocket"],
121
+ });
122
+ installDefaultLogging(socket);
123
+ socket.on("connect", () => {
124
+ live.attempt = 0;
125
+ });
126
+ socket.on("connect_error", scheduleReresolve);
127
+ attach(socket, subscriptions);
128
+ return socket;
129
+ };
130
+ /**
131
+ * A socket that will never connect, for the case where the token is readable
132
+ * and the port is not.
133
+ *
134
+ * That window is not a race to lose sleep over — it is minutes wide on a cold
135
+ * start, because `setupSandbox()` (which can build a Docker image) runs
136
+ * between the server writing the token and binding its port. Connecting to
137
+ * `DEFAULT_API_URL` there would hand a freshly minted bearer token to whatever
138
+ * holds 3001 (Codex, #3078). Waiting is the only safe answer, and the
139
+ * supervisor is already the thing that waits.
140
+ */
141
+ const openIdle = () => io(DEFAULT_API_URL, { path: CHAT_SOCKET_PATH, transports: ["websocket"], autoConnect: false });
142
+ /** Everything the supervisor mutates, boxed so every binding stays `const`.
143
+ * Built after `open` / `openIdle` because it holds the socket they make;
144
+ * they only READ it from callbacks, which cannot fire before it exists. */
145
+ const live = {
146
+ socket: startAddress === null ? openIdle() : open(startedAt),
147
+ current: startedAt,
148
+ attempt: 0,
149
+ retry: null,
150
+ closed: false,
151
+ };
152
+ if (startAddress === null) {
153
+ console.error("\nThe server has not published a port yet — waiting for it rather than guessing.\n");
154
+ scheduleReresolve();
155
+ }
156
+ /** Replace the socket only when the pair actually moved — a server that is
157
+ * merely down must keep socket.io's own reconnection, not a worse copy. */
158
+ function reresolve() {
159
+ live.retry = null;
160
+ if (live.closed)
161
+ return;
162
+ const fresh = reread();
163
+ live.attempt += 1;
164
+ if (!credentialsChanged(live.current, fresh) || fresh === null) {
165
+ // Keep waiting. A LIVE socket would re-arm this itself through its next
166
+ // `connect_error`, but the idle socket built when nothing was published
167
+ // never connects and so never emits one — without this the wait is
168
+ // single-shot and a bridge started before its server would hang forever.
169
+ // The `retry !== null` guard in `scheduleReresolve` stops the two paths
170
+ // from doubling up, and the backoff caps the cost of an idle wait.
171
+ if (!live.socket.connected)
172
+ scheduleReresolve();
173
+ return;
174
+ }
175
+ console.error(`\nServer moved: reconnecting to ${fresh.apiUrl}.\n`);
176
+ abandon(pending, "the server restarted before this was acknowledged — resend");
177
+ live.socket.removeAllListeners();
178
+ live.socket.close();
179
+ live.current = fresh;
180
+ live.attempt = 0;
181
+ live.socket = open(live.current);
182
+ }
183
+ function scheduleReresolve() {
184
+ if (live.closed || live.retry !== null)
185
+ return;
186
+ // NOT `unref()`ed. A bridge waiting for its server to publish a port is
187
+ // doing work, and while it waits the idle socket (`autoConnect: false`)
188
+ // holds nothing — so an unref'd timer let the process exit immediately
189
+ // after printing that it would wait. `close()` clears this, so holding the
190
+ // loop open costs nothing on the way out (Codex, #3078).
191
+ live.retry = setTimeout(reresolve, backoffMs(live.attempt));
192
+ }
70
193
  return {
71
- send: (externalChatId, text, attachments) => sendMessage(socket, externalChatId, text, attachments),
194
+ send: (externalChatId, text, attachments) => sendMessage(live.socket, pending, externalChatId, text, attachments),
72
195
  onPush: (handler) => {
73
- socket.on(CHAT_SOCKET_EVENTS.push, handler);
196
+ subscriptions.push.push(handler);
197
+ live.socket.on(CHAT_SOCKET_EVENTS.push, handler);
74
198
  },
75
199
  onTextChunk: (handler) => {
76
- socket.on(CHAT_SOCKET_EVENTS.textChunk, (event) => {
200
+ subscriptions.textChunk.push(handler);
201
+ live.socket.on(CHAT_SOCKET_EVENTS.textChunk, (event) => {
77
202
  handler(event.text);
78
203
  });
79
204
  },
80
205
  onConnect: (handler) => {
81
- socket.on("connect", handler);
206
+ subscriptions.connect.push(handler);
207
+ live.socket.on("connect", handler);
82
208
  },
83
209
  onDisconnect: (handler) => {
84
- socket.on("disconnect", handler);
210
+ subscriptions.disconnect.push(handler);
211
+ live.socket.on("disconnect", handler);
85
212
  },
86
213
  close: () => {
87
- socket.disconnect();
214
+ live.closed = true;
215
+ if (live.retry !== null)
216
+ clearTimeout(live.retry);
217
+ abandon(pending, "the bridge closed before this was acknowledged");
218
+ live.socket.disconnect();
219
+ },
220
+ get socket() {
221
+ return live.socket;
88
222
  },
89
- socket,
90
223
  };
91
224
  }
92
- function sendMessage(socket, externalChatId, text, attachments) {
225
+ function sendMessage(socket, pending, externalChatId, text, attachments) {
93
226
  const payload = { externalChatId, text };
94
227
  if (attachments && attachments.length > 0)
95
228
  payload.attachments = attachments;
96
229
  return new Promise((resolve) => {
97
- socket.timeout(REPLY_TIMEOUT_MS).emit(CHAT_SOCKET_EVENTS.message, payload, (err, ack) => {
98
- if (err) {
99
- resolve({ ok: false, error: `timeout: ${err.message}` });
230
+ // The timeout is OURS, not `socket.timeout(...)`'s, because it has to be
231
+ // CANCELLABLE. socket.io arms its ack timer at emit time and keeps it armed
232
+ // on a socket that is closed underneath it, so a send abandoned by a rebuild
233
+ // 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
235
+ // (Codex, #3078). `settle` clears it, so `abandon` clears it too.
236
+ const state = {};
237
+ const settle = (ack) => {
238
+ if (!pending.delete(settle))
100
239
  return;
101
- }
102
- resolve(ack ?? { ok: false, error: "no ack from server" });
240
+ clearTimeout(state.timer);
241
+ resolve(ack);
242
+ };
243
+ state.timer = setTimeout(() => settle({ ok: false, error: `timeout: no ack within ${REPLY_TIMEOUT_MS}ms` }), REPLY_TIMEOUT_MS);
244
+ pending.add(settle);
245
+ socket.emit(CHAT_SOCKET_EVENTS.message, payload, (ack) => {
246
+ settle(ack ?? { ok: false, error: "no ack from server" });
103
247
  });
104
248
  });
105
249
  }
250
+ /**
251
+ * Fail every unacknowledged send, because the socket carrying them is going.
252
+ *
253
+ * socket.io settles an IN-FLIGHT ack immediately when its socket closes, but a
254
+ * send issued while the socket was already disconnected is queued for a
255
+ * 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
+ * client already knows it cannot deliver.
259
+ */
260
+ function abandon(pending, reason) {
261
+ Array.from(pending).forEach((settle) => settle({ ok: false, error: reason }));
262
+ pending.clear();
263
+ }
106
264
  function installDefaultLogging(socket) {
107
265
  socket.on("connect", () => {
108
266
  console.log(`Connected (${socket.id}).`);
@@ -117,9 +275,11 @@ function installDefaultLogging(socket) {
117
275
  // right after the server bounces. Tell the user instead of
118
276
  // spinning silently.
119
277
  if (msg === "invalid token" || msg === "server auth not ready") {
120
- console.error("\nConnect error: bearer token rejected. The server likely\n" +
121
- "restarted since this bridge started — re-run the bridge to\n" +
122
- "pick up the new token.\n");
278
+ // No longer "re-run the bridge": the client re-reads the sidecar pair
279
+ // after every connect failure and rebuilds the socket when the server
280
+ // comes back as a different generation (#3078 A-3). This says what is
281
+ // happening so a run that never recovers is still diagnosable.
282
+ console.error("\nConnect error: bearer token rejected — waiting for the server to publish a new one.\n");
123
283
  return;
124
284
  }
125
285
  console.error(`\nConnect error: ${msg}`);
@@ -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
@@ -1,9 +1,11 @@
1
1
  export { createBridgeClient, requireBearerToken, type MessageAck, type PushEvent, type BridgeClientOptions, type BridgeClient } from "./client.js";
2
2
  export { readBridgeToken, tokenFilePath, TOKEN_FILE_PATH } from "./token.js";
3
- export { resolveApiUrl } from "./apiUrl.js";
3
+ export { resolveApiUrl, resolvePublishedApiUrl } from "./apiUrl.js";
4
4
  export { readBridgeEnvOptions } from "./options.js";
5
5
  export { chunkText } from "./text.js";
6
6
  export { frameText } from "./frame.js";
7
7
  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
+ 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
@@ -1,10 +1,12 @@
1
1
  // @mulmobridge/client — shared socket.io client for all MulmoBridge bridges.
2
2
  export { createBridgeClient, requireBearerToken } from "./client.js";
3
3
  export { readBridgeToken, tokenFilePath, TOKEN_FILE_PATH } from "./token.js";
4
- export { resolveApiUrl } from "./apiUrl.js";
4
+ export { resolveApiUrl, resolvePublishedApiUrl } from "./apiUrl.js";
5
5
  export { readBridgeEnvOptions } from "./options.js";
6
6
  export { chunkText } from "./text.js";
7
7
  export { frameText } from "./frame.js";
8
8
  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
+ export { installProcessGuards, SHUTDOWN_GRACE_MS } from "./processGuards.js";
12
+ export { createInProcessBridgeClient, } from "./inProcess.js";
@@ -0,0 +1,16 @@
1
+ /** Release resources and stop accepting work. May be async. */
2
+ export type ShutdownTask = () => void | Promise<void>;
3
+ export interface ProcessGuardOptions {
4
+ /** Transport id, used as the log prefix so the line says WHICH bridge died. */
5
+ name: string;
6
+ /** Runs once, on the first signal, before the process exits. */
7
+ onShutdown?: ShutdownTask;
8
+ /** Test seam; production ends the process. */
9
+ exit?: (code: number) => void;
10
+ /** How long a shutdown task may take before the process leaves anyway.
11
+ * Defaults to `SHUTDOWN_GRACE_MS`; tests shorten it. */
12
+ graceMs?: number;
13
+ }
14
+ /** A shutdown task that hangs must not hold the terminal hostage. */
15
+ export declare const SHUTDOWN_GRACE_MS = 5000;
16
+ export declare function installProcessGuards(opts: ProcessGuardOptions): void;
@@ -0,0 +1,84 @@
1
+ // Process-level guards for a bridge (#3084).
2
+ //
3
+ // A bridge is a long-lived process a user starts in a terminal and leaves
4
+ // running, and none of the 25 had any of these:
5
+ //
6
+ // - `unhandledRejection` — Node 15+ terminates the process, so ONE missed
7
+ // `await` anywhere took the bot down leaving a bare stack trace that names
8
+ // no bridge. From the outside that is "the bot just stopped answering".
9
+ // - `uncaughtException` — the same, for a throw off the call stack.
10
+ // - `SIGINT` / `SIGTERM` — Ctrl-C killed the process mid-flight, dropping a
11
+ // webhook that was being handled or updates already fetched and unprocessed.
12
+ //
13
+ // Installing a handler for the first two SUPPRESSES Node's own exit, so both
14
+ // re-exit explicitly: the aim is a legible message, not a survivable error. No
15
+ // restart logic lives here — a supervisor belongs to whatever started the
16
+ // bridge (#3080), and two of them would fight.
17
+ import { errorMessage } from "@mulmoclaude/common";
18
+ /** A shutdown task that hangs must not hold the terminal hostage. */
19
+ export const SHUTDOWN_GRACE_MS = 5_000;
20
+ export function installProcessGuards(opts) {
21
+ const exit = opts.exit ?? ((code) => process.exit(code));
22
+ installCrashGuards(opts.name, exit);
23
+ installSignalGuards(opts, exit);
24
+ }
25
+ function installCrashGuards(name, exit) {
26
+ process.on("unhandledRejection", (reason) => {
27
+ console.error(`[${name}] unhandled rejection — exiting: ${errorMessage(reason)}`);
28
+ if (reason instanceof Error && reason.stack !== undefined)
29
+ console.error(reason.stack);
30
+ exit(1);
31
+ });
32
+ process.on("uncaughtException", (err) => {
33
+ console.error(`[${name}] uncaught exception — exiting: ${errorMessage(err)}`);
34
+ if (err instanceof Error && err.stack !== undefined)
35
+ console.error(err.stack);
36
+ exit(1);
37
+ });
38
+ }
39
+ function installSignalGuards(opts, exit) {
40
+ let shuttingDown = false;
41
+ const handle = (signal) => {
42
+ if (shuttingDown) {
43
+ // Someone pressed Ctrl-C twice because the first one looked stuck. Honour
44
+ // the impatience rather than waiting out the grace period.
45
+ console.error(`[${opts.name}] ${signal} again — exiting now`);
46
+ exit(1);
47
+ return;
48
+ }
49
+ shuttingDown = true;
50
+ console.log(`[${opts.name}] ${signal} — shutting down`);
51
+ void runShutdown(opts.name, opts.onShutdown, opts.graceMs ?? SHUTDOWN_GRACE_MS).then(() => exit(0));
52
+ };
53
+ ["SIGINT", "SIGTERM"].forEach((signal) => process.on(signal, () => handle(signal)));
54
+ }
55
+ async function runShutdown(name, task, graceMs) {
56
+ if (task === undefined)
57
+ return;
58
+ // The deadline timer stays REFERENCED, and is cleared once the race settles.
59
+ // An `unref`ed one looks tidier and silently breaks the guarantee: a shutdown
60
+ // task that hangs after the last other handle closed lets Node empty its loop
61
+ // and exit before the timer fires, so neither the message below nor the
62
+ // `exit(0)` that follows this call ever runs. Keeping it referenced is what
63
+ // holds the process open for exactly as long as the grace period.
64
+ let deadline;
65
+ try {
66
+ await Promise.race([
67
+ Promise.resolve(task()),
68
+ new Promise((resolve) => {
69
+ deadline = setTimeout(() => {
70
+ console.error(`[${name}] shutdown did not finish within ${graceMs}ms — exiting anyway`);
71
+ resolve();
72
+ }, graceMs);
73
+ }),
74
+ ]);
75
+ }
76
+ catch (err) {
77
+ console.error(`[${name}] shutdown task failed: ${errorMessage(err)}`);
78
+ }
79
+ finally {
80
+ // A fast shutdown must not wait out the rest of the grace period.
81
+ if (deadline !== undefined)
82
+ clearTimeout(deadline);
83
+ }
84
+ }
@@ -0,0 +1,16 @@
1
+ /** The pair a socket was built from. */
2
+ export interface Credentials {
3
+ apiUrl: string;
4
+ token: string;
5
+ }
6
+ /** Did the server come back as a different generation? */
7
+ export declare function credentialsChanged(current: Credentials, fresh: Credentials | null): boolean;
8
+ /**
9
+ * Exponential backoff, capped.
10
+ *
11
+ * A restart takes seconds, so the first few re-reads should be quick; a server
12
+ * that is down for the afternoon should not have its workspace stat-ed twice a
13
+ * second until someone notices. Pure, so the schedule is testable without a
14
+ * clock — `attempt` is 0-based and anything below 0 is treated as the first try.
15
+ */
16
+ export declare function backoffMs(attempt: number): number;
@@ -0,0 +1,45 @@
1
+ // Following the server across a restart (#3078 A-3).
2
+ //
3
+ // Both sidecars are rewritten when the server restarts, and the socket's URL is
4
+ // fixed when the socket is constructed — so a bridge that reads them once is
5
+ // pinned to the generation it started against. Before this, the client detected
6
+ // the resulting `invalid token` and told the user to re-run the bridge, which is
7
+ // where "I restart the server and then restart every bridge by hand" came from.
8
+ //
9
+ // `invalid token` is not a sufficient trigger. It only arrives when the bridge
10
+ // still REACHES the server, i.e. when the port happened not to change. When the
11
+ // port did change, nothing answers and the error is a refused connection, so a
12
+ // supervisor watching only for auth failures would sit on a dead port forever.
13
+ // Every connect failure therefore re-resolves.
14
+ //
15
+ // What it deliberately does NOT do is rebuild on every failure. A server that is
16
+ // simply down produces an unbroken stream of refusals, and tearing the socket
17
+ // down for each one would replace socket.io's own reconnection with a worse copy
18
+ // of it. The pair changing is the signal; everything else is left alone.
19
+ //
20
+ // That leaning on socket.io has one edge: a server-initiated disconnect
21
+ // (`io server disconnect`) is the one reason socket.io does NOT retry, so no
22
+ // connect failure follows it and nothing here would fire. It is not handled
23
+ // because the chat-service never issues one — it only logs disconnects — and a
24
+ // recovery path for an event nothing produces is a path nothing tests. If that
25
+ // changes, this is where it would go.
26
+ /** Did the server come back as a different generation? */
27
+ export function credentialsChanged(current, fresh) {
28
+ if (fresh === null)
29
+ return false;
30
+ return fresh.apiUrl !== current.apiUrl || fresh.token !== current.token;
31
+ }
32
+ const FIRST_RETRY_MS = 500;
33
+ const MAX_RETRY_MS = 30_000;
34
+ /**
35
+ * Exponential backoff, capped.
36
+ *
37
+ * A restart takes seconds, so the first few re-reads should be quick; a server
38
+ * that is down for the afternoon should not have its workspace stat-ed twice a
39
+ * second until someone notices. Pure, so the schedule is testable without a
40
+ * clock — `attempt` is 0-based and anything below 0 is treated as the first try.
41
+ */
42
+ export function backoffMs(attempt) {
43
+ const step = attempt > 0 ? attempt : 0;
44
+ return Math.min(FIRST_RETRY_MS * 2 ** step, MAX_RETRY_MS);
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/client",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",
@@ -46,11 +46,11 @@
46
46
  "author": "Receptron Team",
47
47
  "dependencies": {
48
48
  "@mulmobridge/protocol": "^1.0.1",
49
- "@mulmoclaude/common": "^1.2.0",
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.5.1",
54
54
  "typescript": "^6.0.3"
55
55
  },
56
56
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/client#readme",