@msgmesh/sdk
Advanced tools
+86
-4
@@ -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; |
+25
-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 @@ } |
+25
-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 @@ } |
+86
-4
@@ -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 @@ |
130918
22.99%2047
10.17%345
153.68%