@mulmobridge/client 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,8 @@ yarn add @mulmobridge/client
17
17
  | `createBridgeClient(opts)` | Create a connected socket.io client with auth |
18
18
  | `requireBearerToken()` | Read the bearer token or exit with a helpful message |
19
19
  | `readBridgeToken()` | Read the bearer token (returns `null` if absent) |
20
- | `TOKEN_FILE_PATH` | Path to `~/mulmoclaude/.session-token` |
20
+ | `TOKEN_FILE_PATH` | Path to the workspace's `.session-token` |
21
+ | `resolveApiUrl(explicit?)` | Resolve the server URL a bridge should connect to |
21
22
  | `mimeFromExtension(ext)` | Map file extension to MIME type |
22
23
  | `isImageMime(mime)` | Check if MIME is an image type |
23
24
  | `isPdfMime(mime)` | Check if MIME is PDF |
@@ -47,6 +48,39 @@ 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
+ 4. `http://localhost:3001`
64
+
65
+ The workspace itself is `$MULMOCLAUDE_WORKSPACE_PATH`, or `~/mulmoclaude` when
66
+ that is unset — the same rule the server applies, and the same root the bearer
67
+ token is read from.
68
+
69
+ Only `process.env` is consulted. A `.env` file reaches this library through the
70
+ bridge's own `import "dotenv/config"`, which resolves `.env` against the
71
+ process's **current working directory** — so a bridge launched from somewhere
72
+ else does not see a `MULMOCLAUDE_WORKSPACE_PATH` that lives only in the repo's
73
+ `.env`, exactly as it would not see `MULMOCLAUDE_AUTH_TOKEN` there. Export the
74
+ variable, or run the bridge from the directory holding the `.env`.
75
+
76
+ | Export | Resolves |
77
+ |---|---|
78
+ | `readBridgeToken()` / `tokenFilePath()` | at call time |
79
+ | `TOKEN_FILE_PATH` | at import time — a snapshot, kept for compatibility |
80
+
81
+ The port is read once, when the client is created. A server that restarts onto a
82
+ *different* port after that still needs the bridge restarted.
83
+
50
84
  ## Ecosystem
51
85
 
52
86
  Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package family.
@@ -0,0 +1,32 @@
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
+ * Resolution order: explicit argument → `MULMOCLAUDE_API_URL` → the port the
25
+ * server published → `DEFAULT_API_URL`.
26
+ *
27
+ * An explicit value still wins, so nothing that already sets one changes.
28
+ * An EMPTY value falls through instead of being used verbatim, matching how
29
+ * `readBridgeToken` treats an empty `MULMOCLAUDE_AUTH_TOKEN` — an empty
30
+ * string reached `io("")` before this.
31
+ */
32
+ export declare function resolveApiUrl(explicit?: string): string;
package/dist/apiUrl.js ADDED
@@ -0,0 +1,70 @@
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 file is not removed on shutdown, but the server REWRITES it
16
+ // on every startup, so a running server's entry is always current — and with
17
+ // no server running, the old hardcoded 3001 was just as dead.
18
+ import { readSidecarFile, SIDECAR_FILES } from "./workspace.js";
19
+ /** Used only when nothing has been published and nothing was configured. */
20
+ export const DEFAULT_API_URL = "http://localhost:3001";
21
+ const MIN_PORT = 1;
22
+ const MAX_PORT = 65_535;
23
+ /**
24
+ * The published port, or `null` for anything that is not one.
25
+ *
26
+ * Decimal digits only. The writer is `formatServerPort()`, which emits
27
+ * `${port}\n` and nothing else, so there is no lenient shape worth
28
+ * accepting — while `Number.parseInt` would read `3002abc` as 3002 and send
29
+ * the bridge to a port a corrupted file never meant.
30
+ */
31
+ export function parsePublishedPort(raw) {
32
+ if (raw === null)
33
+ return null;
34
+ const trimmed = raw.trim();
35
+ if (!/^\d+$/.test(trimmed))
36
+ return null;
37
+ const port = Number.parseInt(trimmed, 10);
38
+ return port >= MIN_PORT && port <= MAX_PORT ? port : null;
39
+ }
40
+ /**
41
+ * The origin the running server published, or `null` if it published none.
42
+ *
43
+ * `127.0.0.1` rather than `localhost`: the server binds the IPv4 loopback
44
+ * explicitly (`app.listen(port, "127.0.0.1")`), while `localhost` resolves
45
+ * to `::1` first on a dual-stack host. Usually that still works — nothing
46
+ * holds `::1`, the connection is refused and Node falls back to IPv4 — but
47
+ * when something IS there the client reaches it and never falls back, which
48
+ * is the silent misdirection this module exists to remove (#2981).
49
+ */
50
+ export function readPublishedApiUrl() {
51
+ const port = parsePublishedPort(readSidecarFile(SIDECAR_FILES.port));
52
+ return port === null ? null : `http://127.0.0.1:${port}`;
53
+ }
54
+ /**
55
+ * Resolution order: explicit argument → `MULMOCLAUDE_API_URL` → the port the
56
+ * server published → `DEFAULT_API_URL`.
57
+ *
58
+ * An explicit value still wins, so nothing that already sets one changes.
59
+ * An EMPTY value falls through instead of being used verbatim, matching how
60
+ * `readBridgeToken` treats an empty `MULMOCLAUDE_AUTH_TOKEN` — an empty
61
+ * string reached `io("")` before this.
62
+ */
63
+ export function resolveApiUrl(explicit) {
64
+ if (typeof explicit === "string" && explicit.length > 0)
65
+ return explicit;
66
+ const fromEnv = process.env.MULMOCLAUDE_API_URL;
67
+ if (typeof fromEnv === "string" && fromEnv.length > 0)
68
+ return fromEnv;
69
+ return readPublishedApiUrl() ?? DEFAULT_API_URL;
70
+ }
package/dist/client.d.ts CHANGED
@@ -14,7 +14,9 @@ export interface BridgeClientOptions {
14
14
  /** Required. Identifier for this bridge in the handshake.
15
15
  * Matches `handshake.auth.transportId` server-side. */
16
16
  transportId: string;
17
- /** Defaults to `$MULMOCLAUDE_API_URL` or `http://localhost:3001`. */
17
+ /** Defaults to `$MULMOCLAUDE_API_URL`, then the port the server
18
+ * published to `<workspace>/.server-port`, then
19
+ * `http://localhost:3001` (#3078). */
18
20
  apiUrl?: string;
19
21
  /** Flat primitive bag forwarded to the host app's startChat
20
22
  * callback via the handshake (`BridgeOptions` from the
package/dist/client.js CHANGED
@@ -13,12 +13,12 @@
13
13
  // minimal non-Node equivalent.
14
14
  import { io } from "socket.io-client";
15
15
  import { CHAT_SOCKET_EVENTS, CHAT_SOCKET_PATH } from "@mulmobridge/protocol";
16
- import { readBridgeToken, TOKEN_FILE_PATH } from "./token.js";
16
+ import { readBridgeToken, tokenFilePath } from "./token.js";
17
17
  import { readBridgeEnvOptions } from "./options.js";
18
+ import { resolveApiUrl } from "./apiUrl.js";
18
19
  // 6 min > the server's REPLY_TIMEOUT_MS (5 min) so the server's
19
20
  // timeout surfaces as a reply, not a client-side cancellation.
20
21
  const REPLY_TIMEOUT_MS = 6 * 60 * 1000;
21
- const DEFAULT_API_URL = "http://localhost:3001";
22
22
  /**
23
23
  * Resolve the bearer token from the workspace / env var, exit with
24
24
  * a clear error if absent. Kept separate so bridges that want to
@@ -29,16 +29,29 @@ export function requireBearerToken() {
29
29
  const token = readBridgeToken();
30
30
  if (token !== null)
31
31
  return token;
32
+ // `tokenFilePath()` rather than `TOKEN_FILE_PATH`, which is fixed at module
33
+ // load: a bridge that imports this before `dotenv/config` would otherwise be
34
+ // told to look somewhere the token was never going to be.
32
35
  process.stderr.write(`No bearer token found. The MulmoClaude server writes one to\n` +
33
- ` ${TOKEN_FILE_PATH}\n` +
36
+ ` ${tokenFilePath()}\n` +
34
37
  `at startup (mode 0600). Start the server with \`yarn dev\` (or\n` +
35
38
  `\`npm run dev\`) first, or set MULMOCLAUDE_AUTH_TOKEN to the\n` +
36
39
  `same value the server is using.\n`);
37
40
  return process.exit(1);
38
41
  }
39
42
  export function createBridgeClient(opts) {
40
- const apiUrl = opts.apiUrl ?? process.env.MULMOCLAUDE_API_URL ?? DEFAULT_API_URL;
43
+ // Token BEFORE port. A restart rewrites both files and nothing marks them as
44
+ // one generation, so a bridge starting mid-restart can read a torn pair in
45
+ // either order. What the order decides is WHICH tear it gets. Port first
46
+ // yields a NEW token with an OLD port — a fresh credential sent to the port
47
+ // the server has just left, retried in silence because the socket's URL is
48
+ // fixed at construction. Token first mostly yields the opposite, an OLD token
49
+ // with a NEW port, which the right server answers `invalid token` and the
50
+ // connect handler explains; the dangerous pairing survives only in the narrow
51
+ // window where BOTH reads fall between the token write and the port publish.
52
+ // Closing it needs a shared generation marker on the sidecars (Codex, #3082).
41
53
  const token = requireBearerToken();
54
+ const apiUrl = resolveApiUrl(opts.apiUrl);
42
55
  // `opts.options === undefined` → scrape env automatically.
43
56
  // `opts.options === {}` → opt out of the scrape explicitly.
44
57
  const options = opts.options ?? readBridgeEnvOptions(opts.transportId, process.env);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
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 } from "./apiUrl.js";
3
4
  export { readBridgeEnvOptions } from "./options.js";
4
5
  export { chunkText } from "./text.js";
5
6
  export { frameText } from "./frame.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
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 } from "./apiUrl.js";
4
5
  export { readBridgeEnvOptions } from "./options.js";
5
6
  export { chunkText } from "./text.js";
6
7
  export { frameText } from "./frame.js";
package/dist/token.d.ts CHANGED
@@ -1,2 +1,15 @@
1
+ /**
2
+ * Where the token file is, resolved NOW.
3
+ *
4
+ * Prefer this over `TOKEN_FILE_PATH` for anything that reads, writes, or
5
+ * reports the path: the constant freezes the workspace root at import time, so
6
+ * it disagrees with `readBridgeToken()` for any process that sets
7
+ * `MULMOCLAUDE_WORKSPACE_PATH` after importing this package (Codex, #3078).
8
+ */
9
+ export declare function tokenFilePath(): string;
10
+ /** Public since #272, so it stays. It now honours `MULMOCLAUDE_WORKSPACE_PATH`
11
+ * (#3078) instead of hardcoding `<homedir>/mulmoclaude`, but it is a snapshot
12
+ * taken at import time — use `tokenFilePath()` unless you specifically want
13
+ * the value as it stood when the module loaded. */
1
14
  export declare const TOKEN_FILE_PATH: string;
2
15
  export declare function readBridgeToken(): string | null;
package/dist/token.js CHANGED
@@ -4,26 +4,34 @@
4
4
  // Resolution order:
5
5
  // 1. `MULMOCLAUDE_AUTH_TOKEN` env var (useful for parallel shells,
6
6
  // CI, or when the user runs the bridge against a different
7
- // workspace than ~/mulmoclaude/)
8
- // 2. `<homedir>/mulmoclaude/.session-token` — the file the server
9
- // writes at startup. Same path the Vite dev plugin reads from.
7
+ // workspace than the server's)
8
+ // 2. `<workspace>/.session-token` — the file the server writes at
9
+ // startup. Same path the Vite dev plugin reads from, and the
10
+ // pair of `.server-port` (see `workspace.ts`).
10
11
  //
11
12
  // Returns null if neither source yields a non-empty string — the
12
13
  // caller decides how to react (exit with a helpful message, in the
13
14
  // bridge's case).
14
- import fs from "fs";
15
- import os from "os";
16
- import path from "path";
17
- export const TOKEN_FILE_PATH = path.join(os.homedir(), "mulmoclaude", ".session-token");
15
+ import { readSidecarFile, SIDECAR_FILES, sidecarPath } from "./workspace.js";
16
+ /**
17
+ * Where the token file is, resolved NOW.
18
+ *
19
+ * Prefer this over `TOKEN_FILE_PATH` for anything that reads, writes, or
20
+ * reports the path: the constant freezes the workspace root at import time, so
21
+ * it disagrees with `readBridgeToken()` for any process that sets
22
+ * `MULMOCLAUDE_WORKSPACE_PATH` after importing this package (Codex, #3078).
23
+ */
24
+ export function tokenFilePath() {
25
+ return sidecarPath(SIDECAR_FILES.token);
26
+ }
27
+ /** Public since #272, so it stays. It now honours `MULMOCLAUDE_WORKSPACE_PATH`
28
+ * (#3078) instead of hardcoding `<homedir>/mulmoclaude`, but it is a snapshot
29
+ * taken at import time — use `tokenFilePath()` unless you specifically want
30
+ * the value as it stood when the module loaded. */
31
+ export const TOKEN_FILE_PATH = sidecarPath(SIDECAR_FILES.token);
18
32
  export function readBridgeToken() {
19
33
  const fromEnv = process.env.MULMOCLAUDE_AUTH_TOKEN;
20
34
  if (typeof fromEnv === "string" && fromEnv.length > 0)
21
35
  return fromEnv;
22
- try {
23
- const raw = fs.readFileSync(TOKEN_FILE_PATH, "utf-8").trim();
24
- return raw.length > 0 ? raw : null;
25
- }
26
- catch {
27
- return null;
28
- }
36
+ return readSidecarFile(SIDECAR_FILES.token);
29
37
  }
@@ -0,0 +1,23 @@
1
+ /** Sidecar files the server rewrites on every startup. */
2
+ export declare const SIDECAR_FILES: {
3
+ readonly token: ".session-token";
4
+ readonly port: ".server-port";
5
+ };
6
+ /**
7
+ * The workspace root the server is using.
8
+ *
9
+ * Same rule as the server's own `workspacePath`
10
+ * (`server/workspace/paths.ts`): `MULMOCLAUDE_WORKSPACE_PATH` wins,
11
+ * otherwise `<homedir>/mulmoclaude`. The server's extra test-env
12
+ * branch is deliberately not mirrored — it isolates the server's own
13
+ * integration runs and means nothing to a bridge process.
14
+ */
15
+ export declare function workspaceRoot(): string;
16
+ /** Absolute path of one sidecar file inside the workspace. */
17
+ export declare function sidecarPath(fileName: string): string;
18
+ /**
19
+ * Sidecar contents, trimmed. `null` when the file is absent,
20
+ * unreadable, or holds nothing but whitespace — all of which mean the
21
+ * same thing to a caller: the server has not told us this yet.
22
+ */
23
+ export declare function readSidecarFile(fileName: string): string | null;
@@ -0,0 +1,54 @@
1
+ // Where a bridge looks for the sidecar files the server writes on every
2
+ // startup (#3078).
3
+ //
4
+ // The server publishes two files into its workspace root at boot:
5
+ // `.session-token` (the bearer token the bridge presents) and
6
+ // `.server-port` (the port it actually bound, which is NOT always the
7
+ // one it was asked for — see `server/workspace/serverPort.ts`). They
8
+ // are a PAIR, rewritten together on every restart, so both are
9
+ // resolved from one root here rather than each growing its own idea of
10
+ // where the workspace is. Reading only one of them is how the bridges
11
+ // ended up following the token across restarts while still addressing
12
+ // a hardcoded `localhost:3001`.
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ const DEFAULT_WORKSPACE_DIR = "mulmoclaude";
17
+ /** Sidecar files the server rewrites on every startup. */
18
+ export const SIDECAR_FILES = {
19
+ token: ".session-token",
20
+ port: ".server-port",
21
+ };
22
+ /**
23
+ * The workspace root the server is using.
24
+ *
25
+ * Same rule as the server's own `workspacePath`
26
+ * (`server/workspace/paths.ts`): `MULMOCLAUDE_WORKSPACE_PATH` wins,
27
+ * otherwise `<homedir>/mulmoclaude`. The server's extra test-env
28
+ * branch is deliberately not mirrored — it isolates the server's own
29
+ * integration runs and means nothing to a bridge process.
30
+ */
31
+ export function workspaceRoot() {
32
+ const configured = process.env.MULMOCLAUDE_WORKSPACE_PATH;
33
+ if (typeof configured === "string" && configured.length > 0)
34
+ return configured;
35
+ return path.join(os.homedir(), DEFAULT_WORKSPACE_DIR);
36
+ }
37
+ /** Absolute path of one sidecar file inside the workspace. */
38
+ export function sidecarPath(fileName) {
39
+ return path.join(workspaceRoot(), fileName);
40
+ }
41
+ /**
42
+ * Sidecar contents, trimmed. `null` when the file is absent,
43
+ * unreadable, or holds nothing but whitespace — all of which mean the
44
+ * same thing to a caller: the server has not told us this yet.
45
+ */
46
+ export function readSidecarFile(fileName) {
47
+ try {
48
+ const raw = fs.readFileSync(sidecarPath(fileName), "utf-8").trim();
49
+ return raw.length > 0 ? raw : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/client",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Socket.io client library for MulmoBridge — shared by all bridge implementations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,11 +46,11 @@
46
46
  "author": "Receptron Team",
47
47
  "dependencies": {
48
48
  "@mulmobridge/protocol": "^1.0.1",
49
- "@mulmoclaude/common": "^1.1.2",
49
+ "@mulmoclaude/common": "^1.2.0",
50
50
  "socket.io-client": "^4.0.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@types/node": "^26.1.2",
53
+ "@types/node": "^26.4.1",
54
54
  "typescript": "^6.0.3"
55
55
  },
56
56
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/client#readme",