@mulmobridge/client 0.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 ADDED
@@ -0,0 +1,62 @@
1
+ # @mulmobridge/client
2
+
3
+ Shared socket.io client library for all MulmoBridge bridges. Handles connection setup, bearer-token authentication, and the send/receive wire protocol so each bridge only needs to implement its platform adapter.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @mulmobridge/client
9
+ # or
10
+ yarn add @mulmobridge/client
11
+ ```
12
+
13
+ ## Exports
14
+
15
+ | Export | Description |
16
+ |---|---|
17
+ | `createBridgeClient(opts)` | Create a connected socket.io client with auth |
18
+ | `requireBearerToken()` | Read the bearer token or exit with a helpful message |
19
+ | `readBridgeToken()` | Read the bearer token (returns `null` if absent) |
20
+ | `TOKEN_FILE_PATH` | Path to `~/mulmoclaude/.session-token` |
21
+ | `mimeFromExtension(ext)` | Map file extension to MIME type |
22
+ | `isImageMime(mime)` | Check if MIME is an image type |
23
+ | `isPdfMime(mime)` | Check if MIME is PDF |
24
+ | `isSupportedAttachmentMime(mime)` | Check if MIME can be sent to Claude |
25
+ | `parseDataUrl(url)` | Parse `data:mime;base64,data` strings |
26
+ | `buildDataUrl(mime, b64)` | Build a data URL from components |
27
+ | `MessageAck` | Acknowledgement returned by `client.send()` |
28
+ | `PushEvent` | Server-push event delivered to `client.onPush()` |
29
+ | `BridgeClientOptions` | Options accepted by `createBridgeClient()` |
30
+ | `BridgeClient` | Client interface returned by `createBridgeClient()` |
31
+ | `ParsedDataUrl` | Parsed data URL components |
32
+
33
+ ## Usage
34
+
35
+ ```typescript
36
+ import { createBridgeClient } from "@mulmobridge/client";
37
+
38
+ const client = createBridgeClient({ transportId: "my-bridge" });
39
+
40
+ const ack = await client.send("chat-123", "Hello!");
41
+ if (ack.ok) {
42
+ console.log(ack.reply);
43
+ }
44
+
45
+ client.onPush((ev) => {
46
+ console.log(`Push from ${ev.chatId}: ${ev.message}`);
47
+ });
48
+ ```
49
+
50
+ ## Ecosystem
51
+
52
+ Part of the `@mulmobridge/*` package family:
53
+
54
+ - **@mulmobridge/protocol** — shared types and constants
55
+ - **@mulmobridge/client** — this package
56
+ - **@mulmobridge/cli** — interactive terminal bridge
57
+ - **@mulmobridge/telegram** — Telegram bot bridge
58
+ - **@mulmobridge/chat-service** — server-side chat service
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,41 @@
1
+ import { type Socket } from "socket.io-client";
2
+ import { type Attachment } from "@mulmobridge/protocol";
3
+ export interface MessageAck {
4
+ ok: boolean;
5
+ reply?: string;
6
+ error?: string;
7
+ status?: number;
8
+ }
9
+ export interface PushEvent {
10
+ chatId: string;
11
+ message: string;
12
+ }
13
+ export interface BridgeClientOptions {
14
+ /** Required. Identifier for this bridge in the handshake.
15
+ * Matches `handshake.auth.transportId` server-side. */
16
+ transportId: string;
17
+ /** Defaults to `$MULMOCLAUDE_API_URL` or `http://localhost:3001`. */
18
+ apiUrl?: string;
19
+ }
20
+ export interface BridgeClient {
21
+ /** Send a user turn to MulmoClaude, wait for the assistant reply. */
22
+ send(externalChatId: string, text: string, attachments?: Attachment[]): Promise<MessageAck>;
23
+ /** Subscribe to server → bridge async pushes (Phase B of #268). */
24
+ onPush(handler: (event: PushEvent) => void): void;
25
+ /** Called each time the socket (re-)establishes a connection. */
26
+ onConnect(handler: () => void): void;
27
+ /** Called when the socket disconnects. */
28
+ onDisconnect(handler: (reason: string) => void): void;
29
+ /** Explicit shutdown. */
30
+ close(): void;
31
+ /** Escape hatch — raw socket for anything the helpers don't cover. */
32
+ socket: Socket;
33
+ }
34
+ /**
35
+ * Resolve the bearer token from the workspace / env var, exit with
36
+ * a clear error if absent. Kept separate so bridges that want to
37
+ * surface the error differently (e.g. print to a platform channel)
38
+ * can call `readBridgeToken()` directly.
39
+ */
40
+ export declare function requireBearerToken(): string;
41
+ export declare function createBridgeClient(opts: BridgeClientOptions): BridgeClient;
package/dist/client.js ADDED
@@ -0,0 +1,101 @@
1
+ // Shared socket.io client wrapper for every MulmoClaude bridge.
2
+ //
3
+ // A bridge is a small process that glues one external messaging
4
+ // platform (CLI / Telegram / LINE / Slack / …) to MulmoClaude's
5
+ // chat-service. Every bridge needs the exact same socket setup:
6
+ // read the bearer token, connect to `/ws/chat` with
7
+ // `{ transportId, token }`, handle connect / disconnect / token-
8
+ // mismatch, and send / receive on the two wire events (`message`
9
+ // with ack, `push` from the server). That machinery lives here so
10
+ // each new bridge file is just the platform adapter.
11
+ //
12
+ // See `docs/bridge-protocol.md` for the wire-level contract and a
13
+ // minimal non-Node equivalent.
14
+ import { io } from "socket.io-client";
15
+ import { CHAT_SOCKET_EVENTS, CHAT_SOCKET_PATH, } from "@mulmobridge/protocol";
16
+ import { readBridgeToken, TOKEN_FILE_PATH } from "./token.js";
17
+ // 6 min > the server's REPLY_TIMEOUT_MS (5 min) so the server's
18
+ // timeout surfaces as a reply, not a client-side cancellation.
19
+ const REPLY_TIMEOUT_MS = 6 * 60 * 1000;
20
+ const DEFAULT_API_URL = "http://localhost:3001";
21
+ /**
22
+ * Resolve the bearer token from the workspace / env var, exit with
23
+ * a clear error if absent. Kept separate so bridges that want to
24
+ * surface the error differently (e.g. print to a platform channel)
25
+ * can call `readBridgeToken()` directly.
26
+ */
27
+ export function requireBearerToken() {
28
+ const token = readBridgeToken();
29
+ if (token !== null)
30
+ return token;
31
+ process.stderr.write(`No bearer token found. The MulmoClaude server writes one to\n` +
32
+ ` ${TOKEN_FILE_PATH}\n` +
33
+ `at startup (mode 0600). Start the server with \`yarn dev\` (or\n` +
34
+ `\`npm run dev\`) first, or set MULMOCLAUDE_AUTH_TOKEN to the\n` +
35
+ `same value the server is using.\n`);
36
+ process.exit(1);
37
+ }
38
+ export function createBridgeClient(opts) {
39
+ const apiUrl = opts.apiUrl ?? process.env.MULMOCLAUDE_API_URL ?? DEFAULT_API_URL;
40
+ const token = requireBearerToken();
41
+ const socket = io(apiUrl, {
42
+ path: CHAT_SOCKET_PATH,
43
+ auth: { transportId: opts.transportId, token },
44
+ transports: ["websocket"],
45
+ });
46
+ installDefaultLogging(socket);
47
+ return {
48
+ send: (externalChatId, text, attachments) => sendMessage(socket, externalChatId, text, attachments),
49
+ onPush: (handler) => {
50
+ socket.on(CHAT_SOCKET_EVENTS.push, handler);
51
+ },
52
+ onConnect: (handler) => {
53
+ socket.on("connect", handler);
54
+ },
55
+ onDisconnect: (handler) => {
56
+ socket.on("disconnect", handler);
57
+ },
58
+ close: () => {
59
+ socket.disconnect();
60
+ },
61
+ socket,
62
+ };
63
+ }
64
+ function sendMessage(socket, externalChatId, text, attachments) {
65
+ const payload = { externalChatId, text };
66
+ if (attachments && attachments.length > 0)
67
+ payload.attachments = attachments;
68
+ return new Promise((resolve) => {
69
+ socket
70
+ .timeout(REPLY_TIMEOUT_MS)
71
+ .emit(CHAT_SOCKET_EVENTS.message, payload, (err, ack) => {
72
+ if (err) {
73
+ resolve({ ok: false, error: `timeout: ${err.message}` });
74
+ return;
75
+ }
76
+ resolve(ack ?? { ok: false, error: "no ack from server" });
77
+ });
78
+ });
79
+ }
80
+ function installDefaultLogging(socket) {
81
+ socket.on("connect", () => {
82
+ console.log(`Connected (${socket.id}).`);
83
+ });
84
+ socket.on("disconnect", (reason) => {
85
+ console.error(`\nDisconnected: ${reason}`);
86
+ });
87
+ socket.on("connect_error", (err) => {
88
+ const msg = err.message;
89
+ // Token-mismatch recovery: the server rewrites its token on
90
+ // every restart, so an old bridge will see "invalid token"
91
+ // right after the server bounces. Tell the user instead of
92
+ // spinning silently.
93
+ if (msg === "invalid token" || msg === "server auth not ready") {
94
+ console.error("\nConnect error: bearer token rejected. The server likely\n" +
95
+ "restarted since this bridge started — re-run the bridge to\n" +
96
+ "pick up the new token.\n");
97
+ return;
98
+ }
99
+ console.error(`\nConnect error: ${msg}`);
100
+ });
101
+ }
@@ -0,0 +1,3 @@
1
+ export { createBridgeClient, requireBearerToken, type MessageAck, type PushEvent, type BridgeClientOptions, type BridgeClient, } from "./client.js";
2
+ export { readBridgeToken, TOKEN_FILE_PATH } from "./token.js";
3
+ export { mimeFromExtension, isImageMime, isPdfMime, isSupportedAttachmentMime, parseDataUrl, buildDataUrl, type ParsedDataUrl, } from "./mime.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @mulmobridge/client — shared socket.io client for all MulmoBridge bridges.
2
+ export { createBridgeClient, requireBearerToken, } from "./client.js";
3
+ export { readBridgeToken, TOKEN_FILE_PATH } from "./token.js";
4
+ export { mimeFromExtension, isImageMime, isPdfMime, isSupportedAttachmentMime, parseDataUrl, buildDataUrl, } from "./mime.js";
package/dist/mime.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Infer MIME type from a file extension (case-insensitive).
2
+ * Returns the fallback if the extension is not recognised. */
3
+ export declare function mimeFromExtension(ext: string, fallback?: string): string;
4
+ /** True when the MIME type is an image Claude can see via vision. */
5
+ export declare function isImageMime(mimeType: string): boolean;
6
+ /** True when the MIME type is a PDF document Claude can read natively
7
+ * via `type: "document"` content blocks. */
8
+ export declare function isPdfMime(mimeType: string): boolean;
9
+ /** True when the attachment can be sent to Claude as a content block
10
+ * (image vision or PDF document). */
11
+ export declare function isSupportedAttachmentMime(mimeType: string): boolean;
12
+ export interface ParsedDataUrl {
13
+ mimeType: string;
14
+ data: string;
15
+ }
16
+ /** Parse a `data:<mime>;base64,<data>` string. Returns null if the
17
+ * format doesn't match. Also handles parameterised data URLs like
18
+ * `data:image/png;charset=binary;base64,…`. */
19
+ export declare function parseDataUrl(dataUrl: string): ParsedDataUrl | null;
20
+ /** Build a `data:<mime>;base64,<data>` string from components. */
21
+ export declare function buildDataUrl(mimeType: string, base64Data: string): string;
package/dist/mime.js ADDED
@@ -0,0 +1,60 @@
1
+ // Shared MIME type helpers used by both bridges and the server.
2
+ // Pure functions — no I/O, no deps.
3
+ const EXT_TO_MIME = {
4
+ // Images
5
+ jpg: "image/jpeg",
6
+ jpeg: "image/jpeg",
7
+ png: "image/png",
8
+ gif: "image/gif",
9
+ webp: "image/webp",
10
+ svg: "image/svg+xml",
11
+ heic: "image/heic",
12
+ heif: "image/heif",
13
+ bmp: "image/bmp",
14
+ tiff: "image/tiff",
15
+ tif: "image/tiff",
16
+ avif: "image/avif",
17
+ // Documents
18
+ pdf: "application/pdf",
19
+ doc: "application/msword",
20
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
21
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
22
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
23
+ // Video / audio
24
+ mp4: "video/mp4",
25
+ webm: "video/webm",
26
+ mp3: "audio/mpeg",
27
+ ogg: "audio/ogg",
28
+ };
29
+ /** Infer MIME type from a file extension (case-insensitive).
30
+ * Returns the fallback if the extension is not recognised. */
31
+ export function mimeFromExtension(ext, fallback = "application/octet-stream") {
32
+ return EXT_TO_MIME[ext.toLowerCase()] ?? fallback;
33
+ }
34
+ /** True when the MIME type is an image Claude can see via vision. */
35
+ export function isImageMime(mimeType) {
36
+ return mimeType.startsWith("image/");
37
+ }
38
+ /** True when the MIME type is a PDF document Claude can read natively
39
+ * via `type: "document"` content blocks. */
40
+ export function isPdfMime(mimeType) {
41
+ return mimeType === "application/pdf";
42
+ }
43
+ /** True when the attachment can be sent to Claude as a content block
44
+ * (image vision or PDF document). */
45
+ export function isSupportedAttachmentMime(mimeType) {
46
+ return isImageMime(mimeType) || isPdfMime(mimeType);
47
+ }
48
+ /** Parse a `data:<mime>;base64,<data>` string. Returns null if the
49
+ * format doesn't match. Also handles parameterised data URLs like
50
+ * `data:image/png;charset=binary;base64,…`. */
51
+ export function parseDataUrl(dataUrl) {
52
+ const match = dataUrl.match(/^data:([^;,]+)(?:;[^,]*)?;base64,(.+)$/);
53
+ if (!match)
54
+ return null;
55
+ return { mimeType: match[1], data: match[2] };
56
+ }
57
+ /** Build a `data:<mime>;base64,<data>` string from components. */
58
+ export function buildDataUrl(mimeType, base64Data) {
59
+ return `data:${mimeType};base64,${base64Data}`;
60
+ }
@@ -0,0 +1,2 @@
1
+ export declare const TOKEN_FILE_PATH: string;
2
+ export declare function readBridgeToken(): string | null;
package/dist/token.js ADDED
@@ -0,0 +1,29 @@
1
+ // Resolve the bearer token the CLI bridge sends to /api/*. Used by
2
+ // @mulmobridge/cli at startup (#272 Phase 2).
3
+ //
4
+ // Resolution order:
5
+ // 1. `MULMOCLAUDE_AUTH_TOKEN` env var (useful for parallel shells,
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.
10
+ //
11
+ // Returns null if neither source yields a non-empty string — the
12
+ // caller decides how to react (exit with a helpful message, in the
13
+ // 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");
18
+ export function readBridgeToken() {
19
+ const fromEnv = process.env.MULMOCLAUDE_AUTH_TOKEN;
20
+ if (typeof fromEnv === "string" && fromEnv.length > 0)
21
+ 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
+ }
29
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@mulmobridge/client",
3
+ "version": "0.1.0",
4
+ "description": "Socket.io client library for MulmoBridge — shared by all bridge implementations",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "prepack": "yarn build",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "tsx --test test/test_*.ts",
23
+ "lint": "eslint src test"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Receptron Team",
27
+ "dependencies": {
28
+ "@mulmobridge/protocol": "^0.1.0",
29
+ "socket.io-client": "^4.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.8.0"
33
+ }
34
+ }