@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 +62 -1
- package/dist/apiUrl.d.ts +49 -0
- package/dist/apiUrl.js +90 -0
- package/dist/client.d.ts +7 -3
- package/dist/client.js +205 -32
- 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/dist/token.d.ts +13 -0
- package/dist/token.js +22 -14
- package/dist/workspace.d.ts +23 -0
- package/dist/workspace.js +54 -0
- package/package.json +3 -3
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
|
|
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.
|
package/dist/apiUrl.d.ts
ADDED
|
@@ -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
|
|
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 —
|
|
45
|
-
|
|
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,
|
|
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
|
-
` ${
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
196
|
+
subscriptions.push.push(handler);
|
|
197
|
+
live.socket.on(CHAT_SOCKET_EVENTS.push, handler);
|
|
61
198
|
},
|
|
62
199
|
onTextChunk: (handler) => {
|
|
63
|
-
|
|
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
|
-
|
|
206
|
+
subscriptions.connect.push(handler);
|
|
207
|
+
live.socket.on("connect", handler);
|
|
69
208
|
},
|
|
70
209
|
onDisconnect: (handler) => {
|
|
71
|
-
|
|
210
|
+
subscriptions.disconnect.push(handler);
|
|
211
|
+
live.socket.on("disconnect", handler);
|
|
72
212
|
},
|
|
73
213
|
close: () => {
|
|
74
|
-
|
|
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(
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
8
|
-
// 2. `<
|
|
9
|
-
//
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
49
|
+
"@mulmoclaude/common": "^1.3.0",
|
|
50
50
|
"socket.io-client": "^4.0.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@types/node": "^26.1
|
|
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",
|