@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 +30 -3
- package/dist/apiUrl.d.ts +17 -0
- package/dist/apiUrl.js +29 -9
- package/dist/client.d.ts +4 -2
- package/dist/client.js +196 -36
- package/dist/inProcess.d.ts +34 -0
- package/dist/inProcess.js +77 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/processGuards.d.ts +16 -0
- package/dist/processGuards.js +84 -0
- package/dist/supervisor.d.ts +16 -0
- package/dist/supervisor.js +45 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
|
|
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 —
|
|
47
|
-
|
|
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 {
|
|
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
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
196
|
+
subscriptions.push.push(handler);
|
|
197
|
+
live.socket.on(CHAT_SOCKET_EVENTS.push, handler);
|
|
74
198
|
},
|
|
75
199
|
onTextChunk: (handler) => {
|
|
76
|
-
|
|
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
|
-
|
|
206
|
+
subscriptions.connect.push(handler);
|
|
207
|
+
live.socket.on("connect", handler);
|
|
82
208
|
},
|
|
83
209
|
onDisconnect: (handler) => {
|
|
84
|
-
|
|
210
|
+
subscriptions.disconnect.push(handler);
|
|
211
|
+
live.socket.on("disconnect", handler);
|
|
85
212
|
},
|
|
86
213
|
close: () => {
|
|
87
|
-
|
|
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(
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"@mulmoclaude/common": "^1.3.0",
|
|
50
50
|
"socket.io-client": "^4.0.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@types/node": "^26.
|
|
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",
|