@mulmobridge/client 1.0.2 → 1.2.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
@@ -17,7 +17,8 @@ yarn add @mulmobridge/client
17
17
  | `createBridgeClient(opts)` | Create a connected socket.io client with auth |
18
18
  | `requireBearerToken()` | Read the bearer token or exit with a helpful message |
19
19
  | `readBridgeToken()` | Read the bearer token (returns `null` if absent) |
20
- | `TOKEN_FILE_PATH` | Path to `~/mulmoclaude/.session-token` |
20
+ | `TOKEN_FILE_PATH` | Path to the workspace's `.session-token` |
21
+ | `resolveApiUrl(explicit?)` | Resolve the server URL a bridge should connect to |
21
22
  | `mimeFromExtension(ext)` | Map file extension to MIME type |
22
23
  | `isImageMime(mime)` | Check if MIME is an image type |
23
24
  | `isPdfMime(mime)` | Check if MIME is PDF |
@@ -47,6 +48,66 @@ client.onPush((ev) => {
47
48
  });
48
49
  ```
49
50
 
51
+ ## Which server it connects to
52
+
53
+ The MulmoClaude server is **not pinned to port 3001**. It honours `PORT`, and an
54
+ implicit default that is already busy walks forward (`Port 3001 busy → using 3002
55
+ instead`). Whatever it ends up binding, it publishes to `<workspace>/.server-port`
56
+ — the file every out-of-process reader uses to find it.
57
+
58
+ `createBridgeClient()` resolves the address in this order:
59
+
60
+ 1. `opts.apiUrl` — an explicit value always wins
61
+ 2. `$MULMOCLAUDE_API_URL`
62
+ 3. `http://127.0.0.1:<port>` from `<workspace>/.server-port`
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.
82
+
83
+ The workspace itself is `$MULMOCLAUDE_WORKSPACE_PATH`, or `~/mulmoclaude` when
84
+ that is unset — the same rule the server applies, and the same root the bearer
85
+ token is read from.
86
+
87
+ Only `process.env` is consulted. A `.env` file reaches this library through the
88
+ bridge's own `import "dotenv/config"`, which resolves `.env` against the
89
+ process's **current working directory** — so a bridge launched from somewhere
90
+ else does not see a `MULMOCLAUDE_WORKSPACE_PATH` that lives only in the repo's
91
+ `.env`, exactly as it would not see `MULMOCLAUDE_AUTH_TOKEN` there. Export the
92
+ variable, or run the bridge from the directory holding the `.env`.
93
+
94
+ | Export | Resolves |
95
+ |---|---|
96
+ | `readBridgeToken()` / `tokenFilePath()` | at call time |
97
+ | `TOKEN_FILE_PATH` | at import time — a snapshot, kept for compatibility |
98
+
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.
110
+
50
111
  ## Ecosystem
51
112
 
52
113
  Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package family.
@@ -0,0 +1,49 @@
1
+ /** Used only when nothing has been published and nothing was configured. */
2
+ export declare const DEFAULT_API_URL = "http://localhost:3001";
3
+ /**
4
+ * The published port, or `null` for anything that is not one.
5
+ *
6
+ * Decimal digits only. The writer is `formatServerPort()`, which emits
7
+ * `${port}\n` and nothing else, so there is no lenient shape worth
8
+ * accepting — while `Number.parseInt` would read `3002abc` as 3002 and send
9
+ * the bridge to a port a corrupted file never meant.
10
+ */
11
+ export declare function parsePublishedPort(raw: string | null): number | null;
12
+ /**
13
+ * The origin the running server published, or `null` if it published none.
14
+ *
15
+ * `127.0.0.1` rather than `localhost`: the server binds the IPv4 loopback
16
+ * explicitly (`app.listen(port, "127.0.0.1")`), while `localhost` resolves
17
+ * to `::1` first on a dual-stack host. Usually that still works — nothing
18
+ * holds `::1`, the connection is refused and Node falls back to IPv4 — but
19
+ * when something IS there the client reaches it and never falls back, which
20
+ * is the silent misdirection this module exists to remove (#2981).
21
+ */
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;
36
+ /**
37
+ * Resolution order: explicit argument → `MULMOCLAUDE_API_URL` → the port the
38
+ * server published → `DEFAULT_API_URL`.
39
+ *
40
+ * An explicit value still wins, so nothing that already sets one changes.
41
+ * An EMPTY value falls through instead of being used verbatim, matching how
42
+ * `readBridgeToken` treats an empty `MULMOCLAUDE_AUTH_TOKEN` — an empty
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`.
48
+ */
49
+ export declare function resolveApiUrl(explicit?: string): string;
package/dist/apiUrl.js ADDED
@@ -0,0 +1,90 @@
1
+ // Which server a bridge talks to (#3078).
2
+ //
3
+ // The server is not pinned to 3001. `server/index.ts` walks forward off a
4
+ // busy default (`Port 3001 busy → using 3002 instead`) and honours `PORT`,
5
+ // then publishes whatever it actually bound to `<workspace>/.server-port`.
6
+ // A client that does not read that file either connects to nothing (case A:
7
+ // `PORT=3099` and nobody on 3001) or — worse — connects cleanly to a
8
+ // DIFFERENT instance that happens to hold 3001 (case B), which with a shared
9
+ // `MULMOCLAUDE_AUTH_TOKEN` authenticates without a single error.
10
+ //
11
+ // This is the same class as #2650 (Vite's proxy target) and #2981
12
+ // (`wait-for-backend`); both were fixed by reading the published port.
13
+ //
14
+ // A leftover `.server-port` cannot mislead here the way it can mislead
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.
19
+ import { readSidecarFile, SIDECAR_FILES } from "./workspace.js";
20
+ /** Used only when nothing has been published and nothing was configured. */
21
+ export const DEFAULT_API_URL = "http://localhost:3001";
22
+ const MIN_PORT = 1;
23
+ const MAX_PORT = 65_535;
24
+ /**
25
+ * The published port, or `null` for anything that is not one.
26
+ *
27
+ * Decimal digits only. The writer is `formatServerPort()`, which emits
28
+ * `${port}\n` and nothing else, so there is no lenient shape worth
29
+ * accepting — while `Number.parseInt` would read `3002abc` as 3002 and send
30
+ * the bridge to a port a corrupted file never meant.
31
+ */
32
+ export function parsePublishedPort(raw) {
33
+ if (raw === null)
34
+ return null;
35
+ const trimmed = raw.trim();
36
+ if (!/^\d+$/.test(trimmed))
37
+ return null;
38
+ const port = Number.parseInt(trimmed, 10);
39
+ return port >= MIN_PORT && port <= MAX_PORT ? port : null;
40
+ }
41
+ /**
42
+ * The origin the running server published, or `null` if it published none.
43
+ *
44
+ * `127.0.0.1` rather than `localhost`: the server binds the IPv4 loopback
45
+ * explicitly (`app.listen(port, "127.0.0.1")`), while `localhost` resolves
46
+ * to `::1` first on a dual-stack host. Usually that still works — nothing
47
+ * holds `::1`, the connection is refused and Node falls back to IPv4 — but
48
+ * when something IS there the client reaches it and never falls back, which
49
+ * is the silent misdirection this module exists to remove (#2981).
50
+ */
51
+ export function readPublishedApiUrl() {
52
+ const port = parsePublishedPort(readSidecarFile(SIDECAR_FILES.port));
53
+ return port === null ? null : `http://127.0.0.1:${port}`;
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
+ }
75
+ /**
76
+ * Resolution order: explicit argument → `MULMOCLAUDE_API_URL` → the port the
77
+ * server published → `DEFAULT_API_URL`.
78
+ *
79
+ * An explicit value still wins, so nothing that already sets one changes.
80
+ * An EMPTY value falls through instead of being used verbatim, matching how
81
+ * `readBridgeToken` treats an empty `MULMOCLAUDE_AUTH_TOKEN` — an empty
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`.
87
+ */
88
+ export function resolveApiUrl(explicit) {
89
+ return resolvePublishedApiUrl(explicit) ?? DEFAULT_API_URL;
90
+ }
package/dist/client.d.ts CHANGED
@@ -14,7 +14,9 @@ export interface BridgeClientOptions {
14
14
  /** Required. Identifier for this bridge in the handshake.
15
15
  * Matches `handshake.auth.transportId` server-side. */
16
16
  transportId: string;
17
- /** Defaults to `$MULMOCLAUDE_API_URL` or `http://localhost:3001`. */
17
+ /** Defaults to `$MULMOCLAUDE_API_URL`, then the port the server
18
+ * published to `<workspace>/.server-port`, then
19
+ * `http://localhost:3001` (#3078). */
18
20
  apiUrl?: string;
19
21
  /** Flat primitive bag forwarded to the host app's startChat
20
22
  * callback via the handshake (`BridgeOptions` from the
@@ -41,8 +43,10 @@ export interface BridgeClient {
41
43
  onDisconnect(handler: (reason: string) => void): void;
42
44
  /** Explicit shutdown. */
43
45
  close(): void;
44
- /** Escape hatch — raw socket for anything the helpers don't cover. */
45
- 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;
46
50
  }
47
51
  /**
48
52
  * Resolve the bearer token from the workspace / env var, exit with
package/dist/client.js CHANGED
@@ -13,12 +13,13 @@
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 { readBridgeToken, TOKEN_FILE_PATH } from "./token.js";
16
+ import { readBridgeToken, tokenFilePath } from "./token.js";
17
17
  import { readBridgeEnvOptions } from "./options.js";
18
+ import { DEFAULT_API_URL, resolvePublishedApiUrl } from "./apiUrl.js";
19
+ import { backoffMs, credentialsChanged } from "./supervisor.js";
18
20
  // 6 min > the server's REPLY_TIMEOUT_MS (5 min) so the server's
19
21
  // timeout surfaces as a reply, not a client-side cancellation.
20
22
  const REPLY_TIMEOUT_MS = 6 * 60 * 1000;
21
- const DEFAULT_API_URL = "http://localhost:3001";
22
23
  /**
23
24
  * Resolve the bearer token from the workspace / env var, exit with
24
25
  * a clear error if absent. Kept separate so bridges that want to
@@ -29,67 +30,237 @@ export function requireBearerToken() {
29
30
  const token = readBridgeToken();
30
31
  if (token !== null)
31
32
  return token;
33
+ // `tokenFilePath()` rather than `TOKEN_FILE_PATH`, which is fixed at module
34
+ // load: a bridge that imports this before `dotenv/config` would otherwise be
35
+ // told to look somewhere the token was never going to be.
32
36
  process.stderr.write(`No bearer token found. The MulmoClaude server writes one to\n` +
33
- ` ${TOKEN_FILE_PATH}\n` +
37
+ ` ${tokenFilePath()}\n` +
34
38
  `at startup (mode 0600). Start the server with \`yarn dev\` (or\n` +
35
39
  `\`npm run dev\`) first, or set MULMOCLAUDE_AUTH_TOKEN to the\n` +
36
40
  `same value the server is using.\n`);
37
41
  return process.exit(1);
38
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
+ }
39
60
  export function createBridgeClient(opts) {
40
- const apiUrl = opts.apiUrl ?? process.env.MULMOCLAUDE_API_URL ?? DEFAULT_API_URL;
61
+ // Token BEFORE port. A restart rewrites both files and nothing marks them as
62
+ // one generation, so a bridge starting mid-restart can read a torn pair in
63
+ // either order. What the order decides is WHICH tear it gets. Port first
64
+ // yields a NEW token with an OLD port — a fresh credential sent to the port
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).
41
69
  const token = requireBearerToken();
42
70
  // `opts.options === undefined` → scrape env automatically.
43
71
  // `opts.options === {}` → opt out of the scrape explicitly.
44
72
  const options = opts.options ?? readBridgeEnvOptions(opts.transportId, process.env);
45
- // Only include the `options` key in the handshake when there's
46
- // something to send — keeps old servers unaware of the field from
47
- // ever seeing an empty object on the wire.
48
- const auth = { transportId: opts.transportId, token };
49
- if (Object.keys(options).length > 0)
50
- auth.options = options;
51
- const socket = io(apiUrl, {
52
- path: CHAT_SOCKET_PATH,
53
- auth,
54
- transports: ["websocket"],
55
- });
56
- 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
+ }
57
193
  return {
58
- send: (externalChatId, text, attachments) => sendMessage(socket, externalChatId, text, attachments),
194
+ send: (externalChatId, text, attachments) => sendMessage(live.socket, pending, externalChatId, text, attachments),
59
195
  onPush: (handler) => {
60
- socket.on(CHAT_SOCKET_EVENTS.push, handler);
196
+ subscriptions.push.push(handler);
197
+ live.socket.on(CHAT_SOCKET_EVENTS.push, handler);
61
198
  },
62
199
  onTextChunk: (handler) => {
63
- socket.on(CHAT_SOCKET_EVENTS.textChunk, (event) => {
200
+ subscriptions.textChunk.push(handler);
201
+ live.socket.on(CHAT_SOCKET_EVENTS.textChunk, (event) => {
64
202
  handler(event.text);
65
203
  });
66
204
  },
67
205
  onConnect: (handler) => {
68
- socket.on("connect", handler);
206
+ subscriptions.connect.push(handler);
207
+ live.socket.on("connect", handler);
69
208
  },
70
209
  onDisconnect: (handler) => {
71
- socket.on("disconnect", handler);
210
+ subscriptions.disconnect.push(handler);
211
+ live.socket.on("disconnect", handler);
72
212
  },
73
213
  close: () => {
74
- 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;
75
222
  },
76
- socket,
77
223
  };
78
224
  }
79
- function sendMessage(socket, externalChatId, text, attachments) {
225
+ function sendMessage(socket, pending, externalChatId, text, attachments) {
80
226
  const payload = { externalChatId, text };
81
227
  if (attachments && attachments.length > 0)
82
228
  payload.attachments = attachments;
83
229
  return new Promise((resolve) => {
84
- socket.timeout(REPLY_TIMEOUT_MS).emit(CHAT_SOCKET_EVENTS.message, payload, (err, ack) => {
85
- if (err) {
86
- 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))
87
239
  return;
88
- }
89
- 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" });
90
247
  });
91
248
  });
92
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
+ }
93
264
  function installDefaultLogging(socket) {
94
265
  socket.on("connect", () => {
95
266
  console.log(`Connected (${socket.id}).`);
@@ -104,9 +275,11 @@ function installDefaultLogging(socket) {
104
275
  // right after the server bounces. Tell the user instead of
105
276
  // spinning silently.
106
277
  if (msg === "invalid token" || msg === "server auth not ready") {
107
- console.error("\nConnect error: bearer token rejected. The server likely\n" +
108
- "restarted since this bridge started — re-run the bridge to\n" +
109
- "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");
110
283
  return;
111
284
  }
112
285
  console.error(`\nConnect error: ${msg}`);
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { createBridgeClient, requireBearerToken, type MessageAck, type PushEvent, type BridgeClientOptions, type BridgeClient } from "./client.js";
2
- export { readBridgeToken, TOKEN_FILE_PATH } from "./token.js";
2
+ export { readBridgeToken, tokenFilePath, TOKEN_FILE_PATH } from "./token.js";
3
+ export { resolveApiUrl, resolvePublishedApiUrl } from "./apiUrl.js";
3
4
  export { readBridgeEnvOptions } from "./options.js";
4
5
  export { chunkText } from "./text.js";
5
6
  export { frameText } from "./frame.js";
6
7
  export { asJsonRecord, fetchJsonRecord, type JsonRecord } from "./http.js";
7
8
  export { formatAckReply } from "./reply.js";
8
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";
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // @mulmobridge/client — shared socket.io client for all MulmoBridge bridges.
2
2
  export { createBridgeClient, requireBearerToken } from "./client.js";
3
- export { readBridgeToken, TOKEN_FILE_PATH } from "./token.js";
3
+ export { readBridgeToken, tokenFilePath, TOKEN_FILE_PATH } from "./token.js";
4
+ export { resolveApiUrl, resolvePublishedApiUrl } from "./apiUrl.js";
4
5
  export { readBridgeEnvOptions } from "./options.js";
5
6
  export { chunkText } from "./text.js";
6
7
  export { frameText } from "./frame.js";
7
8
  export { asJsonRecord, fetchJsonRecord } from "./http.js";
8
9
  export { formatAckReply } from "./reply.js";
9
10
  export { mimeFromExtension, isImageMime, isPdfMime, isSupportedAttachmentMime, isNativeAttachmentMime, parseDataUrl, buildDataUrl, } from "./mime.js";
11
+ export { installProcessGuards, SHUTDOWN_GRACE_MS } from "./processGuards.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/dist/token.d.ts CHANGED
@@ -1,2 +1,15 @@
1
+ /**
2
+ * Where the token file is, resolved NOW.
3
+ *
4
+ * Prefer this over `TOKEN_FILE_PATH` for anything that reads, writes, or
5
+ * reports the path: the constant freezes the workspace root at import time, so
6
+ * it disagrees with `readBridgeToken()` for any process that sets
7
+ * `MULMOCLAUDE_WORKSPACE_PATH` after importing this package (Codex, #3078).
8
+ */
9
+ export declare function tokenFilePath(): string;
10
+ /** Public since #272, so it stays. It now honours `MULMOCLAUDE_WORKSPACE_PATH`
11
+ * (#3078) instead of hardcoding `<homedir>/mulmoclaude`, but it is a snapshot
12
+ * taken at import time — use `tokenFilePath()` unless you specifically want
13
+ * the value as it stood when the module loaded. */
1
14
  export declare const TOKEN_FILE_PATH: string;
2
15
  export declare function readBridgeToken(): string | null;
package/dist/token.js CHANGED
@@ -4,26 +4,34 @@
4
4
  // Resolution order:
5
5
  // 1. `MULMOCLAUDE_AUTH_TOKEN` env var (useful for parallel shells,
6
6
  // CI, or when the user runs the bridge against a different
7
- // workspace than ~/mulmoclaude/)
8
- // 2. `<homedir>/mulmoclaude/.session-token` — the file the server
9
- // writes at startup. Same path the Vite dev plugin reads from.
7
+ // workspace than the server's)
8
+ // 2. `<workspace>/.session-token` — the file the server writes at
9
+ // startup. Same path the Vite dev plugin reads from, and the
10
+ // pair of `.server-port` (see `workspace.ts`).
10
11
  //
11
12
  // Returns null if neither source yields a non-empty string — the
12
13
  // caller decides how to react (exit with a helpful message, in the
13
14
  // bridge's case).
14
- import fs from "fs";
15
- import os from "os";
16
- import path from "path";
17
- export const TOKEN_FILE_PATH = path.join(os.homedir(), "mulmoclaude", ".session-token");
15
+ import { readSidecarFile, SIDECAR_FILES, sidecarPath } from "./workspace.js";
16
+ /**
17
+ * Where the token file is, resolved NOW.
18
+ *
19
+ * Prefer this over `TOKEN_FILE_PATH` for anything that reads, writes, or
20
+ * reports the path: the constant freezes the workspace root at import time, so
21
+ * it disagrees with `readBridgeToken()` for any process that sets
22
+ * `MULMOCLAUDE_WORKSPACE_PATH` after importing this package (Codex, #3078).
23
+ */
24
+ export function tokenFilePath() {
25
+ return sidecarPath(SIDECAR_FILES.token);
26
+ }
27
+ /** Public since #272, so it stays. It now honours `MULMOCLAUDE_WORKSPACE_PATH`
28
+ * (#3078) instead of hardcoding `<homedir>/mulmoclaude`, but it is a snapshot
29
+ * taken at import time — use `tokenFilePath()` unless you specifically want
30
+ * the value as it stood when the module loaded. */
31
+ export const TOKEN_FILE_PATH = sidecarPath(SIDECAR_FILES.token);
18
32
  export function readBridgeToken() {
19
33
  const fromEnv = process.env.MULMOCLAUDE_AUTH_TOKEN;
20
34
  if (typeof fromEnv === "string" && fromEnv.length > 0)
21
35
  return fromEnv;
22
- try {
23
- const raw = fs.readFileSync(TOKEN_FILE_PATH, "utf-8").trim();
24
- return raw.length > 0 ? raw : null;
25
- }
26
- catch {
27
- return null;
28
- }
36
+ return readSidecarFile(SIDECAR_FILES.token);
29
37
  }
@@ -0,0 +1,23 @@
1
+ /** Sidecar files the server rewrites on every startup. */
2
+ export declare const SIDECAR_FILES: {
3
+ readonly token: ".session-token";
4
+ readonly port: ".server-port";
5
+ };
6
+ /**
7
+ * The workspace root the server is using.
8
+ *
9
+ * Same rule as the server's own `workspacePath`
10
+ * (`server/workspace/paths.ts`): `MULMOCLAUDE_WORKSPACE_PATH` wins,
11
+ * otherwise `<homedir>/mulmoclaude`. The server's extra test-env
12
+ * branch is deliberately not mirrored — it isolates the server's own
13
+ * integration runs and means nothing to a bridge process.
14
+ */
15
+ export declare function workspaceRoot(): string;
16
+ /** Absolute path of one sidecar file inside the workspace. */
17
+ export declare function sidecarPath(fileName: string): string;
18
+ /**
19
+ * Sidecar contents, trimmed. `null` when the file is absent,
20
+ * unreadable, or holds nothing but whitespace — all of which mean the
21
+ * same thing to a caller: the server has not told us this yet.
22
+ */
23
+ export declare function readSidecarFile(fileName: string): string | null;
@@ -0,0 +1,54 @@
1
+ // Where a bridge looks for the sidecar files the server writes on every
2
+ // startup (#3078).
3
+ //
4
+ // The server publishes two files into its workspace root at boot:
5
+ // `.session-token` (the bearer token the bridge presents) and
6
+ // `.server-port` (the port it actually bound, which is NOT always the
7
+ // one it was asked for — see `server/workspace/serverPort.ts`). They
8
+ // are a PAIR, rewritten together on every restart, so both are
9
+ // resolved from one root here rather than each growing its own idea of
10
+ // where the workspace is. Reading only one of them is how the bridges
11
+ // ended up following the token across restarts while still addressing
12
+ // a hardcoded `localhost:3001`.
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ const DEFAULT_WORKSPACE_DIR = "mulmoclaude";
17
+ /** Sidecar files the server rewrites on every startup. */
18
+ export const SIDECAR_FILES = {
19
+ token: ".session-token",
20
+ port: ".server-port",
21
+ };
22
+ /**
23
+ * The workspace root the server is using.
24
+ *
25
+ * Same rule as the server's own `workspacePath`
26
+ * (`server/workspace/paths.ts`): `MULMOCLAUDE_WORKSPACE_PATH` wins,
27
+ * otherwise `<homedir>/mulmoclaude`. The server's extra test-env
28
+ * branch is deliberately not mirrored — it isolates the server's own
29
+ * integration runs and means nothing to a bridge process.
30
+ */
31
+ export function workspaceRoot() {
32
+ const configured = process.env.MULMOCLAUDE_WORKSPACE_PATH;
33
+ if (typeof configured === "string" && configured.length > 0)
34
+ return configured;
35
+ return path.join(os.homedir(), DEFAULT_WORKSPACE_DIR);
36
+ }
37
+ /** Absolute path of one sidecar file inside the workspace. */
38
+ export function sidecarPath(fileName) {
39
+ return path.join(workspaceRoot(), fileName);
40
+ }
41
+ /**
42
+ * Sidecar contents, trimmed. `null` when the file is absent,
43
+ * unreadable, or holds nothing but whitespace — all of which mean the
44
+ * same thing to a caller: the server has not told us this yet.
45
+ */
46
+ export function readSidecarFile(fileName) {
47
+ try {
48
+ const raw = fs.readFileSync(sidecarPath(fileName), "utf-8").trim();
49
+ return raw.length > 0 ? raw : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/client",
3
- "version": "1.0.2",
3
+ "version": "1.2.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.1.2",
49
+ "@mulmoclaude/common": "^1.3.0",
50
50
  "socket.io-client": "^4.0.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@types/node": "^26.1.2",
53
+ "@types/node": "^26.4.1",
54
54
  "typescript": "^6.0.3"
55
55
  },
56
56
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/client#readme",