Sign In

@msgmesh/sdk

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@msgmesh/sdk - npm Package Compare versions

Comparing version
0.1.6
to
0.1.7
+86
-4
dist/index.cjs

@@ -134,2 +134,21 @@ "use strict";

}
function parseEventId(id) {
if (!id) return null;
const dash = id.indexOf("-");
if (dash <= 0 || dash === id.length - 1) return null;
const partition = id.slice(0, dash);
const offsetStr = id.slice(dash + 1);
if (!/^\d+$/.test(partition) || !/^\d+$/.test(offsetStr)) return null;
const offset = Number(offsetStr);
if (!Number.isSafeInteger(offset)) return null;
return { partition, offset };
}
function dedupeSkip(seen, id) {
const parsed = parseEventId(id);
if (!parsed) return false;
const prev = seen.get(parsed.partition);
if (prev !== void 0 && parsed.offset <= prev) return true;
seen.set(parsed.partition, parsed.offset);
return false;
}
var MsgMesh = class {

@@ -562,2 +581,12 @@ apiKey;

*
* 續傳與去重(#16):每則資料訊息帶 `id:<partition>-<offset>` 游標。SDK 記最後見到的 id,重連時帶回
* (getToken 模式由 SDK 自管重連、於 URL 補 `&from=<id>`;apiKey 模式靠 EventSource 原生 Last-Event-ID
* header),伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**(重疊窗會重送同一則),
* SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage——兩模式皆然。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶
* `&from=`。跨 stop()→重新 stream() 續傳即靠它。
* - opts.onResync(可選):伺服器送 `msgmesh-resync`(重播窗不足以補回,無法保證完整)時觸發。SDK 會清掉
* 去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id 對「快照+live」
* 自去重。
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定

@@ -571,4 +600,9 @@ * 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的

let failures = 0;
let lastEventId = opts?.from;
const seen = /* @__PURE__ */ new Map();
const roomQ = opts?.room ? `&room=${encodeURIComponent(opts.room)}` : "";
const sseUrl = (cred) => `${this.rt}/v1/topics/${encodeURIComponent(topic)}/sse?key=${encodeURIComponent(cred)}${roomQ}`;
const sseUrl = (cred) => {
const fromQ = lastEventId ? `&from=${encodeURIComponent(lastEventId)}` : "";
return `${this.rt}/v1/topics/${encodeURIComponent(topic)}/sse?key=${encodeURIComponent(cred)}${roomQ}${fromQ}`;
};
const scheduleReconnect = () => {

@@ -601,3 +635,7 @@ if (stopped) return;

};
es.onmessage = (e) => onMessage(e.data);
es.onmessage = (e) => {
if (e.lastEventId) lastEventId = e.lastEventId;
if (dedupeSkip(seen, e.lastEventId)) return;
onMessage(e.data);
};
es.onerror = (e) => {

@@ -618,2 +656,7 @@ onError?.(e);

});
es.addEventListener("msgmesh-resync", () => {
seen.clear();
lastEventId = void 0;
opts?.onResync?.();
});
};

@@ -643,2 +686,13 @@ void connect();

*
* 續傳與去重(#47 stage-2,鏡射 stream()):streamWs **一律**以 `?resume=1` 連線進入 envelope 模式
*(嚴格續傳、對齊 SSE 已預設)。伺服器每則資料訊息回線上信封 `{"id":"<partition>-<offset>","data":"<value>"}`,
* SDK 透明解包後把原始 value(缺 data=空 payload→空字串)原樣交 onMessage(**簽名不變**)。SDK 記最後見到的 id,
* 斷線重連時於 URL 補 `&from=<id>`,伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**
*(重疊窗會重送同一則),SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶 `&from=`。
* - opts.onResync(可選):伺服器送 in-band 控制信封 `{"event":"msgmesh-resync"}`(重播窗不足以補回,無法保證完整)
* 時觸發。SDK 會清掉去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id
* 對「快照+live」自去重。撤權仍走 CLOSE 1008(見上),不經 envelope。
* - 防呆/向後相容:若收到**非 envelope**(無 id/event 的 JSON,或非 JSON)訊息,fail-open 原樣投遞、不去重(不誤丟)。
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。

@@ -658,5 +712,10 @@ * ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。

let reconnectTimer;
let lastEventId = opts?.from;
const seen = /* @__PURE__ */ new Map();
const wsBase = this.rt.replace(/^http/, "ws");
const roomQ = opts?.room ? `&room=${encodeURIComponent(opts.room)}` : "";
const wsUrl = (cred) => `${wsBase}/v1/topics/${encodeURIComponent(topic)}/ws?key=${encodeURIComponent(cred)}${roomQ}`;
const wsUrl = (cred) => {
const fromQ = lastEventId ? `&from=${encodeURIComponent(lastEventId)}` : "";
return `${wsBase}/v1/topics/${encodeURIComponent(topic)}/ws?key=${encodeURIComponent(cred)}${roomQ}&resume=1${fromQ}`;
};
const scheduleReconnect = () => {

@@ -695,3 +754,26 @@ if (stopped) return;

};
ws.onmessage = (e) => onMessage(typeof e.data === "string" ? e.data : String(e.data));
ws.onmessage = (e) => {
const raw = typeof e.data === "string" ? e.data : String(e.data);
let env;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") env = parsed;
} catch {
}
if (env && typeof env.event === "string") {
if (env.event === "msgmesh-resync") {
seen.clear();
lastEventId = void 0;
opts?.onResync?.();
}
return;
}
if (env && typeof env.id === "string") {
lastEventId = env.id;
if (dedupeSkip(seen, env.id)) return;
onMessage(typeof env.data === "string" ? env.data : "");
return;
}
onMessage(raw);
};
ws.onclose = (e) => {

@@ -698,0 +780,0 @@ if (stopped) return;

@@ -425,2 +425,12 @@ interface MsgMeshOptions {

*
* 續傳與去重(#16):每則資料訊息帶 `id:<partition>-<offset>` 游標。SDK 記最後見到的 id,重連時帶回
* (getToken 模式由 SDK 自管重連、於 URL 補 `&from=<id>`;apiKey 模式靠 EventSource 原生 Last-Event-ID
* header),伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**(重疊窗會重送同一則),
* SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage——兩模式皆然。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶
* `&from=`。跨 stop()→重新 stream() 續傳即靠它。
* - opts.onResync(可選):伺服器送 `msgmesh-resync`(重播窗不足以補回,無法保證完整)時觸發。SDK 會清掉
* 去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id 對「快照+live」
* 自去重。
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定

@@ -432,2 +442,4 @@ * 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的

room?: string;
from?: string;
onResync?: () => void;
}): () => void;

@@ -451,2 +463,13 @@ /**

*
* 續傳與去重(#47 stage-2,鏡射 stream()):streamWs **一律**以 `?resume=1` 連線進入 envelope 模式
*(嚴格續傳、對齊 SSE 已預設)。伺服器每則資料訊息回線上信封 `{"id":"<partition>-<offset>","data":"<value>"}`,
* SDK 透明解包後把原始 value(缺 data=空 payload→空字串)原樣交 onMessage(**簽名不變**)。SDK 記最後見到的 id,
* 斷線重連時於 URL 補 `&from=<id>`,伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**
*(重疊窗會重送同一則),SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶 `&from=`。
* - opts.onResync(可選):伺服器送 in-band 控制信封 `{"event":"msgmesh-resync"}`(重播窗不足以補回,無法保證完整)
* 時觸發。SDK 會清掉去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id
* 對「快照+live」自去重。撤權仍走 CLOSE 1008(見上),不經 envelope。
* - 防呆/向後相容:若收到**非 envelope**(無 id/event 的 JSON,或非 JSON)訊息,fail-open 原樣投遞、不去重(不誤丟)。
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。

@@ -457,2 +480,4 @@ * ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。

room?: string;
from?: string;
onResync?: () => void;
}): () => void;

@@ -459,0 +484,0 @@ }

@@ -425,2 +425,12 @@ interface MsgMeshOptions {

*
* 續傳與去重(#16):每則資料訊息帶 `id:<partition>-<offset>` 游標。SDK 記最後見到的 id,重連時帶回
* (getToken 模式由 SDK 自管重連、於 URL 補 `&from=<id>`;apiKey 模式靠 EventSource 原生 Last-Event-ID
* header),伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**(重疊窗會重送同一則),
* SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage——兩模式皆然。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶
* `&from=`。跨 stop()→重新 stream() 續傳即靠它。
* - opts.onResync(可選):伺服器送 `msgmesh-resync`(重播窗不足以補回,無法保證完整)時觸發。SDK 會清掉
* 去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id 對「快照+live」
* 自去重。
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定

@@ -432,2 +442,4 @@ * 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的

room?: string;
from?: string;
onResync?: () => void;
}): () => void;

@@ -451,2 +463,13 @@ /**

*
* 續傳與去重(#47 stage-2,鏡射 stream()):streamWs **一律**以 `?resume=1` 連線進入 envelope 模式
*(嚴格續傳、對齊 SSE 已預設)。伺服器每則資料訊息回線上信封 `{"id":"<partition>-<offset>","data":"<value>"}`,
* SDK 透明解包後把原始 value(缺 data=空 payload→空字串)原樣交 onMessage(**簽名不變**)。SDK 記最後見到的 id,
* 斷線重連時於 URL 補 `&from=<id>`,伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**
*(重疊窗會重送同一則),SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶 `&from=`。
* - opts.onResync(可選):伺服器送 in-band 控制信封 `{"event":"msgmesh-resync"}`(重播窗不足以補回,無法保證完整)
* 時觸發。SDK 會清掉去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id
* 對「快照+live」自去重。撤權仍走 CLOSE 1008(見上),不經 envelope。
* - 防呆/向後相容:若收到**非 envelope**(無 id/event 的 JSON,或非 JSON)訊息,fail-open 原樣投遞、不去重(不誤丟)。
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。

@@ -457,2 +480,4 @@ * ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。

room?: string;
from?: string;
onResync?: () => void;
}): () => void;

@@ -459,0 +484,0 @@ }

@@ -101,2 +101,21 @@ // src/errors.ts

}
function parseEventId(id) {
if (!id) return null;
const dash = id.indexOf("-");
if (dash <= 0 || dash === id.length - 1) return null;
const partition = id.slice(0, dash);
const offsetStr = id.slice(dash + 1);
if (!/^\d+$/.test(partition) || !/^\d+$/.test(offsetStr)) return null;
const offset = Number(offsetStr);
if (!Number.isSafeInteger(offset)) return null;
return { partition, offset };
}
function dedupeSkip(seen, id) {
const parsed = parseEventId(id);
if (!parsed) return false;
const prev = seen.get(parsed.partition);
if (prev !== void 0 && parsed.offset <= prev) return true;
seen.set(parsed.partition, parsed.offset);
return false;
}
var MsgMesh = class {

@@ -529,2 +548,12 @@ apiKey;

*
* 續傳與去重(#16):每則資料訊息帶 `id:<partition>-<offset>` 游標。SDK 記最後見到的 id,重連時帶回
* (getToken 模式由 SDK 自管重連、於 URL 補 `&from=<id>`;apiKey 模式靠 EventSource 原生 Last-Event-ID
* header),伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**(重疊窗會重送同一則),
* SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage——兩模式皆然。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶
* `&from=`。跨 stop()→重新 stream() 續傳即靠它。
* - opts.onResync(可選):伺服器送 `msgmesh-resync`(重播窗不足以補回,無法保證完整)時觸發。SDK 會清掉
* 去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id 對「快照+live」
* 自去重。
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定

@@ -538,4 +567,9 @@ * 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的

let failures = 0;
let lastEventId = opts?.from;
const seen = /* @__PURE__ */ new Map();
const roomQ = opts?.room ? `&room=${encodeURIComponent(opts.room)}` : "";
const sseUrl = (cred) => `${this.rt}/v1/topics/${encodeURIComponent(topic)}/sse?key=${encodeURIComponent(cred)}${roomQ}`;
const sseUrl = (cred) => {
const fromQ = lastEventId ? `&from=${encodeURIComponent(lastEventId)}` : "";
return `${this.rt}/v1/topics/${encodeURIComponent(topic)}/sse?key=${encodeURIComponent(cred)}${roomQ}${fromQ}`;
};
const scheduleReconnect = () => {

@@ -568,3 +602,7 @@ if (stopped) return;

};
es.onmessage = (e) => onMessage(e.data);
es.onmessage = (e) => {
if (e.lastEventId) lastEventId = e.lastEventId;
if (dedupeSkip(seen, e.lastEventId)) return;
onMessage(e.data);
};
es.onerror = (e) => {

@@ -585,2 +623,7 @@ onError?.(e);

});
es.addEventListener("msgmesh-resync", () => {
seen.clear();
lastEventId = void 0;
opts?.onResync?.();
});
};

@@ -610,2 +653,13 @@ void connect();

*
* 續傳與去重(#47 stage-2,鏡射 stream()):streamWs **一律**以 `?resume=1` 連線進入 envelope 模式
*(嚴格續傳、對齊 SSE 已預設)。伺服器每則資料訊息回線上信封 `{"id":"<partition>-<offset>","data":"<value>"}`,
* SDK 透明解包後把原始 value(缺 data=空 payload→空字串)原樣交 onMessage(**簽名不變**)。SDK 記最後見到的 id,
* 斷線重連時於 URL 補 `&from=<id>`,伺服器 seek 補回斷線期漏掉的訊息再接 live。因投遞為 **at-least-once**
*(重疊窗會重送同一則),SDK 一律**依 id per-partition 去重**(offset ≤ 該 partition 已見則跳過)後才呼叫 onMessage。
* - opts.from(可選):續傳起點游標 `<partition>-<offset>`(如上次 stop() 前記下的位置),初次連線即帶 `&from=`。
* - opts.onResync(可選):伺服器送 in-band 控制信封 `{"event":"msgmesh-resync"}`(重播窗不足以補回,無法保證完整)
* 時觸發。SDK 會清掉去重游標與已見狀態,呼叫端應**自行重抓一次歷史快照**(SDK 不管歷史);之後的 live 會依 id
* 對「快照+live」自去重。撤權仍走 CLOSE 1008(見上),不經 envelope。
* - 防呆/向後相容:若收到**非 envelope**(無 id/event 的 JSON,或非 JSON)訊息,fail-open 原樣投遞、不去重(不誤丟)。
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。

@@ -625,5 +679,10 @@ * ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。

let reconnectTimer;
let lastEventId = opts?.from;
const seen = /* @__PURE__ */ new Map();
const wsBase = this.rt.replace(/^http/, "ws");
const roomQ = opts?.room ? `&room=${encodeURIComponent(opts.room)}` : "";
const wsUrl = (cred) => `${wsBase}/v1/topics/${encodeURIComponent(topic)}/ws?key=${encodeURIComponent(cred)}${roomQ}`;
const wsUrl = (cred) => {
const fromQ = lastEventId ? `&from=${encodeURIComponent(lastEventId)}` : "";
return `${wsBase}/v1/topics/${encodeURIComponent(topic)}/ws?key=${encodeURIComponent(cred)}${roomQ}&resume=1${fromQ}`;
};
const scheduleReconnect = () => {

@@ -662,3 +721,26 @@ if (stopped) return;

};
ws.onmessage = (e) => onMessage(typeof e.data === "string" ? e.data : String(e.data));
ws.onmessage = (e) => {
const raw = typeof e.data === "string" ? e.data : String(e.data);
let env;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") env = parsed;
} catch {
}
if (env && typeof env.event === "string") {
if (env.event === "msgmesh-resync") {
seen.clear();
lastEventId = void 0;
opts?.onResync?.();
}
return;
}
if (env && typeof env.id === "string") {
lastEventId = env.id;
if (dedupeSkip(seen, env.id)) return;
onMessage(typeof env.data === "string" ? env.data : "");
return;
}
onMessage(raw);
};
ws.onclose = (e) => {

@@ -665,0 +747,0 @@ if (stopped) return;

+1
-1
{
"name": "@msgmesh/sdk",
"version": "0.1.6",
"version": "0.1.7",
"description": "MsgMesh TypeScript SDK — 多租戶事件總線的收發 / 即時(SSE·WS)/ 治理 client(Node 與瀏覽器通用)。",

@@ -5,0 +5,0 @@ "license": "MIT",

+209
-0
# @msgmesh/sdk
**English** | [繁體中文](#msgmeshsdk--繁體中文)
The TypeScript SDK for MsgMesh — a publish / consume / realtime (SSE / WebSocket) /
governance client for the multi-tenant event bus, universal across Node and the browser
(built on `fetch`). It is the single source of truth for the entire frontend ecosystem's HTTP
contract: both the MCP server and the panel build on it.
Also available for Python: [`msgmesh`](https://pypi.org/project/msgmesh/) — the same API surface in `snake_case`.
## Quick start
Register an account in the panel and issue an API key (shown in plaintext only once), then:
```ts
import { MsgMesh } from "@msgmesh/sdk";
const mq = new MsgMesh({
apiKey: process.env.MSGMESH_KEY, // long-lived key, server-side only
controlPlaneUrl: "https://cp.example.com",
gatewayUrl: "https://gw.example.com",
realtimeUrl: "https://rt.example.com",
});
await mq.createTopic("orders");
await mq.publish("orders", { hello: 1 });
const msgs = await mq.poll("orders", { group: "g1" });
```
## Browsers / untrusted clients: use `getToken`, never embed an API key
Anything in the browser leaks — **never put a long-lived API key in the frontend**. Use a
token-broker instead: your backend (which holds the key) calls `POST /v1/tokens` to exchange it
for a short-lived dp token, and the frontend only ever holds the token. The SDK caches it,
refetches before expiry, and rotates it on SSE reconnect.
```ts
const mq = new MsgMesh({
// no apiKey; provide a function that fetches a short-lived token from your backend
getToken: async () => (await fetch("/api/mm-token")).then((r) => r.json()), // { token, expires_in }
gatewayUrl: "https://gw.example.com",
realtimeUrl: "https://rt.example.com",
});
mq.stream("room.42", (data) => console.log(data)); // SSE; auto-rotates the token on expiry and reconnects
mq.streamWs("room.42", (data) => console.log(data)); // WebSocket; same interface, SDK-managed reconnect
```
Two options for realtime receive, with the same interface, each returning a stop function:
- **`stream` (SSE)**: backed by the browser-native `EventSource` (which auto-reconnects), over
`/…/sse`. **Browser only.**
- **`streamWs` (WebSocket)**: the global `WebSocket` (browser-native; built into Node ≥ 22),
over `/…/ws`. **WebSocket has no native reconnect, so the SDK takes it over**: reconnect with a
1s backoff after each drop, reset the failure counter on a successful connect, and stop once
**consecutive** failures (never having connected) reach the limit — avoiding infinite
reconnect to a revoked credential or a persistently unavailable endpoint; in `getToken` mode
it also rotates the token before reconnecting. Revocation mid-connection is CLOSE 1008
`authorization revoked` (stops immediately); during the handshake (HTTP 401 → CloseEvent 1006)
it is bounded by the failure limit. Good when SSE is blocked by a middlebox, or when you
already have WebSocket infrastructure. Node < 22 has no global `WebSocket` and will throw — use
`subscribe` (long-polling) instead.
**Resume on reconnect (at-least-once, no gaps).** Both `stream` (SSE) and `streamWs` (WebSocket)
resume across reconnects: each message carries a `<partition>-<offset>` cursor, the SDK tracks the
last one seen, and on reconnect it asks the server to replay from there — so messages dropped
during a disconnect are backfilled, not lost. Delivery is **at-least-once**: the SDK dedupes
per-partition by cursor, so a rare overlap is suppressed rather than delivered twice. If the
server can't cover the gap (older than the replay window), it emits a resync signal — pass
`onResync` to be told to re-fetch a snapshot. All of this is transparent: `onMessage` still
receives the raw value string, no API change. (Resume requires the platform's realtime resume
tier; against an older server the stream degrades gracefully to live-tail.)
## Rooms
A single topic can be split into multiple rooms (room = Kafka record key), decoupling "number
of rooms" from "number of topics". Two layers:
**① Routing** — publish with `publish(topic, body, { key: roomId })` to target a room, and
subscribe with the optional `room` (the fourth argument `opts`, same for `stream` / `streamWs`)
to receive only that room:
```js
mq.stream("chat", (data) => console.log(data), undefined, { room: "room-42" }); // only room-42
mq.streamWs("chat", (data) => console.log(data), undefined, { room: "room-42" });
await mq.publish("chat", { text: "hi" }, { key: "room-42" }); // publish to room-42
```
Omitting `room` = receive all messages on the topic (backward compatible). Routing only filters
— it does **not** enforce isolation; a malicious client can switch to someone else's `room` and
eavesdrop on other rooms in the same topic. For real isolation, see ②.
**② Isolation (platform-enforced)** — add the optional `rooms` to a credential's `capabilities`
and the platform enforces that the credential can only send/receive the named rooms (403 on
overreach). `rooms` omitted/empty = all rooms (backward compatible); non-empty = only these. The
typical approach: the backend holds an all-rooms key and **downscopes** it via `POST /v1/tokens`
to mint a short-lived "this room only" token for the frontend (a downscope may only narrow, must
be a subset of the key's capabilities, 403 on overreach):
```ts
// Backend token-broker: downscope an all-rooms key to a short-lived "chat / room-42 only"
// token, returned to the frontend as getToken
const r = await fetch(`${controlPlaneUrl}/v1/tokens`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MSGMESH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
ttl_seconds: 600,
capabilities: [{ ops: ["subscribe", "publish"], topics: ["chat"], rooms: ["room-42"] }],
}),
});
const { token, expires_in } = await r.json(); // return to the frontend; it connects SSE/WS via getToken
```
You can also mint a persistent room-scoped key with
`createKey("key", { capabilities: [{ ops, topics, rooms }] })`. Platform enforcement points:
subscribe (SSE/WS) must carry a `?room` within the allowed set (omitting it = wanting all rooms,
also 403); publish `?key` must be within the allowed set.
> ⚠️ **A room-scoped credential can only use realtime (SSE/WS) + `publish` to its rooms**; it
> **cannot** `poll` / `consume` / DLQ. Those are a whole-topic firehose (the consumer-group
> offset would consume other rooms; one group per room = read amplification) and can't be cleanly
> per-room filtered, so a room-restricted credential always gets 403 (`use realtime SSE/WS
> ?room=`). Use an unrestricted credential when you need poll/consume.
### Room isolation security notes (must read)
- **Isolation strength = the scope of the token you issue.** Isolation only exists when the
backend **downscopes** an all-rooms key into a room-scoped token for the frontend. **Never put
an unrestricted credential (a full key, or a token without `rooms`) into the frontend /
untrusted clients** — that lets anyone change `room` and see all rooms, so isolation is
meaningless.
- **The platform does not verify "who the sender is."** Room isolation governs "which rooms you
can send/receive," not "who you are in the room." Within a room, anyone holding that room's
token can impersonate any sender in the payload. To prevent in-room impersonation: **mint a
token per user on the backend and stamp / verify the sender there**, don't let untrusted
clients self-report identity.
- Note: `presence` (online count) is currently per-topic, not per-room (only leaks an aggregate
number); short-lived tokens are bearer tokens — leaking one = usable for that room until TTL
expires (so keep the TTL short and don't log it).
## Production configuration (must read)
- **Always set the service URLs explicitly**: `controlPlaneUrl` (governance API), `gatewayUrl`
(send/receive), `realtimeUrl` (SSE/WS/presence). When unset, the SDK falls back to local-dev
defaults (`http://localhost:8080/8081/8082`), which are for local use only; when a production
call can't connect, the error message appends a "set controlPlaneUrl/gatewayUrl/realtimeUrl"
hint.
- **Credential handling**: an API key is returned in plaintext only once, at creation — never
commit it to a repo or write it to logs. **Never put any API key in the browser; use `getToken`
instead.**
- **Scopes**: publishing needs `producer`, consuming needs `consumer`, administration needs
`admin` (which covers everything); a data-plane key that both publishes and consumes, or needs
fine-grained access, can use the neutral `key` scope (which requires `capabilities`). `getToken`
mode is data-plane only (send/receive); calling governance endpoints returns 401/403.
## Error handling
Non-2xx responses throw a typed error by status code (all inherit `MsgMeshError` and carry
`status`/`code`/`path`):
```ts
import { ValidationError, AuthError, NotFoundError, RateLimitError } from "@msgmesh/sdk";
try {
await mq.createTopic("Bad Name!");
} catch (e) {
if (e instanceof ValidationError) console.error("invalid argument:", e.message);
else if (e instanceof RateLimitError) console.error("rate limited, retry later");
else throw e;
}
```
| Status | Type | code |
| --- | --- | --- |
| 400 / 422 | `ValidationError` | `validation` |
| 401 / 403 | `AuthError` | `auth` |
| 404 | `NotFoundError` | `not_found` |
| 429 | `RateLimitError` | `rate_limit` |
| other | `MsgMeshError` | `server` |
## API overview
- Topics: `createTopic` / `listTopics` / `deleteTopic`
- Send/receive: `publish` / `poll` / `subscribe` (polling) / `stream` (SSE, browser) / `streamWs`
(WebSocket, browser + Node ≥ 22) / `getPresence`
- Keys: `listKeys` (returns `capabilities` / `name`) / `createKey` (accepts `scope` +
`capabilities`) / `deleteKey`
- Webhooks: `listWebhooks` / `createWebhook` / `deleteWebhook` / `reactivateWebhook`
- Schemas: `registerSchema` / `listSchemas` / `getLatestSchema` / `deleteSchema`
- Functions: `registerFunction` / `getFunction` / `deleteFunction` (JavaScript / WASM)
- Plan: `getPlan` / `setPlan`; usage: `getUsage`
- Settings: `getSettings` / `setStrictTopics` (data-plane topic gate toggle)
- Billing (crypto PAYG prepaid): `getBilling` / `getDepositAddresses` / `getDeposits` /
`getLedger` / `getUsageDebits` / `getDepositStatus`
- Misc: `getSnippet` / `getDocs` / `getAudit`, DLQ `dlqPeek` / `dlqReplay`
> Registration and admin (finance / tenant governance) go through panel sessions, not this SDK.
## Development
```sh
npm test -w @msgmesh/sdk && npm run build -w @msgmesh/sdk
```
---
# @msgmesh/sdk · 繁體中文
[English](#msgmeshsdk) | **繁體中文**
MsgMesh 的 TypeScript SDK(Node / 瀏覽器通用,基於 `fetch`)。是整個前端生態的唯一 HTTP 真相源——MCP server 與面板都複用它。
也有 Python 版:[`msgmesh`](https://pypi.org/project/msgmesh/)(相同 API 面、`snake_case`)。
## 快速開始

@@ -6,0 +215,0 @@