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.7
to
0.1.8
+143
-93
dist/index.cjs

@@ -40,4 +40,5 @@ "use strict";

/**
* 後端為此回應產生的追蹤 ID(取自 X-Request-Id header,或回應 body 的 request_id)。
* 已併入 error message 末尾(` [request_id: xxx]`),回報問題時附上可加速定位。
* The trace ID the server generated for this response (from the `X-Request-Id`
* header, or `request_id` in the body). It is appended to the error message
* (` [request_id: xxx]`) — include it when reporting a problem to speed up triage.
*/

@@ -157,3 +158,3 @@ requestId;

getToken;
/** 短期 token 快取(getToken 模式):值 + 到期時間(epoch ms);refreshing 防併發重取(thundering herd)。 */
/** Short-lived token cache (getToken mode): the value plus its expiry (epoch ms). `refreshing` collapses concurrent refetches into one (thundering herd). */
cachedToken;

@@ -166,3 +167,3 @@ tokenExpiry = 0;

fetchImpl;
/** 未明確指定的服務 URL(base → 選項名),連線失敗時提示使用者設定。 */
/** Service URLs left unset (base → option name), used to tell the caller which one to configure when a connection fails. */
defaulted;

@@ -173,3 +174,3 @@ constructor(opts) {

if (!this.apiKey && !this.getToken) {
throw new Error("msgmesh: \u9700\u63D0\u4F9B apiKey(\u4F3A\u670D\u5668\u7AEF)\u6216 getToken(\u700F\u89BD\u5668/\u4E0D\u53EF\u4FE1\u7AEF)\u5176\u4E00");
throw new Error("msgmesh: provide either apiKey (server-side) or getToken (browser / untrusted client)");
}

@@ -186,4 +187,5 @@ this.cp = (opts.controlPlaneUrl ?? DEFAULT_CP).replace(/\/$/, "");

/**
* credential 回傳目前的鑑權憑證:apiKey 模式回 apiKey;getToken 模式回快取的短期 token,
* 將過期(含 skew)時自動透過 getToken 重取。refreshing 確保併發呼叫只觸發一次重取。
* Returns the current credential: the apiKey in apiKey mode; in getToken mode, the cached
* short-lived token, refetched via getToken once it is about to expire (including skew).
* `refreshing` guarantees concurrent callers trigger only one refetch.
*/

@@ -209,3 +211,3 @@ async credential() {

}
/** invalidateToken 使快取 token 失效,強制下次重取(getToken 模式下遇 401 時用)。 */
/** Invalidates the cached token so the next call refetches (used on a 401 in getToken mode). */
invalidateToken() {

@@ -219,5 +221,7 @@ this.cachedToken = void 0;

/**
* f 發出已鑑權請求。getToken 模式下若收到 401(快取 token 過期/被拒:時鐘偏移、伺服器側 TTL 較短、
* 簽章輪替),失效快取並以新 token 重試一次(401 表示請求未被處理,重試安全,含 POST/DELETE)。
* 連線層錯誤(非 HTTP)由 attempt 包裝設定提示後拋出。
* Issues an authenticated request. In getToken mode a 401 (cached token expired or rejected —
* clock skew, a shorter server-side TTL, signing-key rotation) invalidates the cache and
* retries once with a fresh token. A 401 means the request was never processed, so retrying is
* safe even for POST/DELETE. Connection-level errors (not HTTP) are thrown by `attempt` with a
* configuration hint attached.
*/

@@ -233,3 +237,3 @@ async f(url, init) {

}
/** attempt 包一層 fetch:用預設 URL 連線失敗時,在錯誤訊息附設定提示。 */
/** Wraps fetch: when a connection to a *defaulted* URL fails, append a configuration hint to the error. */
async attempt(url, init) {

@@ -249,3 +253,3 @@ try {

throw new Error(
`msgmesh: \u5617\u8A66\u9023\u7DDA ${safeUrl} \u5931\u6557(${err instanceof Error ? err.message : String(err)}),\u8ACB\u6307\u5B9A ${hit[1]}(\u76EE\u524D\u4F7F\u7528\u672C\u6A5F\u9810\u8A2D\u503C)`,
`msgmesh: failed to connect to ${safeUrl} (${err instanceof Error ? err.message : String(err)}); set ${hit[1]} \u2014 it is currently falling back to the local-dev default`,
{ cause: err }

@@ -269,2 +273,9 @@ );

}
/**
* Deletes a topic. **This destroys data irreversibly**: the topic's messages, its dead-letter
* queue (`.dlq`), schema versions, transform function, and webhook bindings are all removed —
* recreating the same name only gets you a brand-new empty topic.
* Idempotent: deleting a topic that does not exist is not an error. If cleanup is incomplete
* it throws rather than pretending to succeed, so you can simply retry.
*/
async deleteTopic(name) {

@@ -298,7 +309,10 @@ const res = await this.f(`${this.cp}/v1/topics/${encodeURIComponent(name)}`, {

/**
* subscribe 持續輪詢,回傳停止函式。
* opts.onError(可選):每次輪詢出錯時回報。
* 終態 vs 可恢復:**只有 401(金鑰失效/不存在=終態)才永久停止重試**——不再無聲無限地拿失效金鑰重打;
* 403(可能為治理停權 suspended,可自助充值解封→可恢復)與其他暫時性錯誤一律回報後退避續試(維持自癒)。
* 永久停止(401)時若呼叫端未提供 onError,會 console.warn 一行(說明已停、原因、建議提供 onError),避免靜默死掉。
* Polls continuously; returns a stop function.
* `opts.onError` (optional) is called on every failed poll.
* Terminal vs recoverable: **only a 401 (key invalid or gone = terminal) stops retrying for
* good** — no more silently hammering forever with a dead key. A 403 (possibly a governance
* suspension, which the tenant can lift by topping up = recoverable) and any other transient
* error are reported and then retried with backoff, so the subscription still self-heals.
* When it stops permanently and the caller supplied no onError, it emits a single
* console.warn (what stopped, why, and to pass onError) rather than dying silently.
*/

@@ -323,3 +337,3 @@ subscribe(topic, opts, handler) {

console.warn(
"msgmesh: subscribe \u5DF2\u505C\u6B62 \u2014 \u9023\u7E8C\u591A\u6B21 HTTP 401(\u6191\u8B49\u53EF\u80FD\u5DF2\u6C38\u4E45\u64A4\u92B7)\u3002\u8ACB\u65BC\u91CD\u65B0\u53D6\u5F97\u6388\u6B0A\u5F8C\u91CD\u65B0\u8A02\u95B1(\u63D0\u4F9B opts.onError \u53EF\u63A5\u6536\u6B64\u4E8B\u4EF6)\u3002",
"msgmesh: subscribe stopped \u2014 repeated HTTP 401 (the credential may have been permanently revoked). Re-subscribe once you have re-authorized; pass opts.onError to receive this event instead of this warning.",
err

@@ -338,3 +352,3 @@ );

console.warn(
"msgmesh: subscribe \u5DF2\u6C38\u4E45\u505C\u6B62 \u2014 API key \u5931\u6548\u6216\u4E0D\u5B58\u5728(HTTP 401)\u3002\u8ACB\u63D0\u4F9B opts.onError \u63A5\u6536\u6B64\u4E8B\u4EF6,\u4E26\u65BC\u91CD\u65B0\u53D6\u5F97\u6191\u8B49\u5F8C\u91CD\u65B0\u8A02\u95B1\u3002",
"msgmesh: subscribe stopped permanently \u2014 the API key is invalid or does not exist (HTTP 401). Pass opts.onError to receive this event, and re-subscribe once you have a new credential.",
err

@@ -364,3 +378,3 @@ );

}
/** getDocs 取得由租戶 topics+schema 生成的 Markdown 使用文件。 */
/** Fetches Markdown usage docs generated from this tenant's topics and schemas. */
async getDocs() {

@@ -377,6 +391,8 @@ const res = await this.f(`${this.cp}/v1/docs`, { headers: await this.authHeader() });

/**
* 簽發 API key。省略 capabilities → 角色鍵(scope=admin|producer|consumer);
* 提供 capabilities → 細粒度能力鍵(scope 須為 producer/consumer,非 admin),
* 形狀 [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }]。
* rooms 選用(房間隔離):省略/空 = 所有房間;非空 = 僅限這些房間(發佈的 ?key / 訂閱的 ?room),平台強制。
* Issues an API key. Omit `capabilities` for a role key (scope = admin|producer|consumer);
* supply `capabilities` for a fine-grained capability key (scope must be producer/consumer,
* not admin), shaped as
* [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }].
* `rooms` is optional (room isolation): omitted or empty = all rooms; non-empty = only these
* rooms — enforced by the platform on publish (`?key`) and subscribe (`?room`).
*/

@@ -403,3 +419,3 @@ async createKey(scope, opts) {

}
/** getBilling 查詢預付餘額 + 計費狀態 + 續航(加密貨幣 PAYG 帳本)。 */
/** Fetches prepaid balance, billing status, and runway (the crypto PAYG ledger). */
async getBilling() {

@@ -409,3 +425,3 @@ const res = await this.f(`${this.cp}/v1/billing`, { headers: await this.authHeader() });

}
/** getDepositAddresses 取各鏈 watch-only 收款地址(QR / 複製用)。 */
/** Fetches the watch-only receiving address for each chain (for QR display / copy-paste). */
async getDepositAddresses() {

@@ -415,3 +431,3 @@ const res = await this.f(`${this.cp}/v1/billing/deposit-addresses`, { headers: await this.authHeader() });

}
/** getDeposits 列出鏈上 USDT 入帳(分頁:cursor 取自前一頁 next_cursor)。 */
/** Lists on-chain USDT deposits (paginated: `cursor` comes from the previous page's next_cursor). */
async getDeposits(cursor, limit) {

@@ -421,3 +437,3 @@ const res = await this.f(`${this.cp}/v1/billing/deposits${pageQuery(cursor, limit)}`, { headers: await this.authHeader() });

}
/** getLedger 列出帳本流水(分頁)。 */
/** Lists ledger entries (paginated). */
async getLedger(cursor, limit) {

@@ -427,3 +443,3 @@ const res = await this.f(`${this.cp}/v1/billing/ledger${pageQuery(cursor, limit)}`, { headers: await this.authHeader() });

}
/** getUsageDebits 列出每日用量扣款(分頁)。 */
/** Lists daily usage debits (paginated). */
async getUsageDebits(cursor, limit) {

@@ -433,3 +449,3 @@ const res = await this.f(`${this.cp}/v1/billing/usage-debits${pageQuery(cursor, limit)}`, { headers: await this.authHeader() });

}
/** getDepositStatus 自助查某 tx_hash 的入帳狀態(限本租戶;未命中回 found=false)。 */
/** Self-service lookup of a tx_hash's credit status (this tenant only; a miss returns found=false). */
async getDepositStatus(txHash) {

@@ -456,3 +472,3 @@ const res = await this.f(

// --- Settings ---
/** getSettings 取得租戶層級設定(目前:strict_topics)。 */
/** Fetches tenant-level settings (currently just strict_topics). */
async getSettings() {

@@ -463,4 +479,6 @@ const res = await this.f(`${this.cp}/v1/settings`, { headers: await this.authHeader() });

/**
* setStrictTopics 開/關資料面 topic 閘門。開啟後,發/收/SSE/WS 到「未由控制面建立」的 topic 一律 404
*(堵住憑空開 topic、繞過配額/分區/schema);關閉(預設)維持鬆散。
* Turns the data-plane topic gate on or off. Once on, publishing / consuming / SSE / WS
* against a topic that was never created through the control plane returns 404 — closing the
* door on conjuring topics out of thin air and bypassing quota, partitioning, and schema.
* Off (the default) stays permissive.
*/

@@ -495,3 +513,3 @@ async setStrictTopics(enabled) {

}
/** reactivateWebhook 重新啟用被停權(如 URL 未過 SSRF 防護)的 webhook;查無此 webhook 會拋 NotFoundError。 */
/** Reactivates a suspended webhook (e.g. one whose URL failed SSRF protection); throws NotFoundError if it does not exist. */
async reactivateWebhook(id) {

@@ -504,6 +522,7 @@ const res = await this.f(`${this.cp}/v1/webhooks/${encodeURIComponent(id)}/reactivate`, {

}
// --- Functions(綁定 topic 的訊息轉換函數)---
// --- Functions (message-transform functions bound to a topic) ---
/**
* registerFunction 為 topic 註冊轉換函數。language 預設 javascript(goja JS 沙箱);
* 傳 "wasm" 則 code 須為 base64 編碼的 WASI 模組二進位(讀 stdin JSON、寫 stdout JSON)。
* Registers a transform function on a topic. `language` defaults to javascript (goja JS
* sandbox); pass "wasm" and `code` must be the base64-encoded WASI module binary (which
* reads JSON on stdin and writes JSON to stdout).
*/

@@ -548,3 +567,3 @@ async registerFunction(topic, code, language) {

}
/** deleteSchema 刪除 topic 的指定 schema 版本(不可刪 latest:409;無此版:404)。 */
/** Deletes one schema version from a topic (409 if it is the latest — that one cannot be deleted; 404 if the version does not exist). */
async deleteSchema(topic, version) {

@@ -570,4 +589,5 @@ const res = await this.f(

/**
* dlqReplay 將 DLQ 訊息重放回主 topic。回傳 replayed(本次重放筆數)與 has_more
* (DLQ 是否仍有可重放訊息;true 表可再次呼叫續放,用於分批清空大量 DLQ)。
* Replays DLQ messages back onto the main topic. Returns `replayed` (how many this call moved)
* and `has_more` (whether the DLQ still holds replayable messages; true means call again to
* continue — useful for draining a large DLQ in batches).
*/

@@ -583,5 +603,6 @@ async dlqReplay(topic, max) {

/**
* getPresence 查詢 topic 目前線上連線數(realtime 服務)。
* 鑑權走 Authorization header(非 query key):此為一般 fetch,可設 header,避免把 API Key
* 放進 query→realtime 存取日誌。僅 stream()(EventSource 無法設 header)才用 query key。
* Fetches a topic's current online connection count (from the realtime service).
* Authenticates with an Authorization header, not a query key: this is an ordinary fetch so a
* header is possible, which keeps the API key out of the query string — and therefore out of
* realtime's access log. Only stream() uses a query key, because EventSource cannot set headers.
*/

@@ -596,27 +617,39 @@ async getPresence(topic) {

/**
* stream 透過 SSE 即時接收訊息(僅瀏覽器:依賴 EventSource)。回傳停止函式。
* 連線鑑權走 query key(瀏覽器無法設 Authorization header)。
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a
* stop function. The connection authenticates with a query key, because a browser cannot set
* an Authorization header on an EventSource.
*
* onError 會收到兩類事件:
* - EventSource 連線錯誤(Event)。
* - 伺服器具名控制事件 msgmesh-close(MessageEvent,e.data 為原因字串)。
* `"authorization revoked"`=撤權(終態),SDK 主動停止重連,呼叫端應提示重新登入。
* `onError` receives two kinds of event:
* - An EventSource connection error (`Event`).
* - The server's named control event `msgmesh-close` (`MessageEvent`, reason in `e.data`).
* `"authorization revoked"` is terminal: the SDK stops reconnecting on its own, and the
* caller should prompt the user to sign in again.
*
* 重連:apiKey 模式交 EventSource 原生重連(撤權靠 msgmesh-close 收口)。getToken 模式由 SDK 接管——
* 任何連線錯誤都以「新 token」重連(失效快取),避免拿過期 token 死循環;並對「連續」失敗設上限
* (MAX_AUTH_RETRIES),達上限視為永久撤權而停止(一次成功連上即歸零),以免對已撤權者無限重連。
* **Reconnect.** In apiKey mode, EventSource's native reconnect handles it (revocation is closed
* out by `msgmesh-close`). In getToken mode the SDK takes over: every connection error
* reconnects with a *fresh* token (the cache is invalidated), so an expired token can never spin
* forever; and *consecutive* failures are capped (MAX_AUTH_RETRIES), after which it treats the
* credential as permanently revoked and stops. Any successful connect resets the counter.
*
* 續傳與去重(#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」
* 自去重。
* **Resume and dedupe (#16).** Every data message carries an `id:<partition>-<offset>` cursor.
* The SDK remembers the last id it saw and sends it back on reconnect (getToken mode manages its
* own reconnect and appends `&from=<id>`; apiKey mode relies on EventSource's native
* `Last-Event-ID` header). The server seeks back, backfills what was missed while disconnected,
* then rejoins live. Because delivery is **at-least-once** (the overlap window resends
* messages), the SDK always **dedupes per partition by id** — skipping any offset <= the highest
* already seen for that partition — before calling `onMessage`. This holds in both modes.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* you recorded before the last `stop()`); sent as `&from=` on the very first connect. This is
* what makes resume work across `stop()` → `stream()` again.
* - `opts.onResync` (optional): fires when the server sends `msgmesh-resync` — its replay window
* could not cover the gap, so completeness is not guaranteed. The SDK clears the resume cursor
* and dedupe state; the caller should **re-fetch a history snapshot itself** (the SDK does not
* manage history). Later live messages dedupe by id against "snapshot + live" on their own.
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定
* 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的
* room 偷聽同 topic 其他房間;真隔離需 token 帶 room scope,MVP 階段隔離靠你的後端 token-broker + 誠實 client。
* `opts.room` (optional, multi-room routing): receive only messages whose Kafka record key
* equals `room` (publish targets a room via publish's `key`). Omitting it receives everything on
* the topic. ⚠️ `room` is only a routing filter — **the server does not enforce isolation**. A
* malicious client can switch to someone else's `room` and eavesdrop on other rooms in the same
* topic. Real isolation requires a token carrying a room scope; at the MVP stage, isolation
* rests on your backend token-broker plus an honest client.
*/

@@ -639,3 +672,3 @@ stream(topic, onMessage, onError, opts) {

console.warn(
"msgmesh: stream \u5DF2\u505C\u6B62 \u2014 \u9023\u7E8C\u591A\u6B21\u9023\u7DDA\u5931\u6557(\u6191\u8B49\u53EF\u80FD\u5DF2\u6C38\u4E45\u64A4\u92B7)\u3002\u8ACB\u65BC\u91CD\u65B0\u53D6\u5F97\u6388\u6B0A\u5F8C\u91CD\u65B0\u547C\u53EB stream()\u3002"
"msgmesh: stream stopped \u2014 repeated connection failures (the credential may have been permanently revoked). Call stream() again once you have re-authorized."
);

@@ -695,31 +728,48 @@ return;

/**
* streamWs 透過 WebSocket 即時接收訊息,介面與 stream(SSE)一致、回傳停止函式。用全域 `WebSocket`
* (瀏覽器原生;Node ≥ 22 亦內建全域 WebSocket)——環境無全域 WebSocket 時立即拋錯(Node < 22 請改用
* subscribe() 長輪詢,或把 ws 套件掛到 globalThis.WebSocket)。連線鑑權走 query key(WebSocket 無法設 header)。
* Receives messages in realtime over WebSocket. Same interface as stream() (SSE) and likewise
* returns a stop function. Uses the global `WebSocket` (native in browsers; also built into
* Node >= 22) — it throws immediately when no global WebSocket exists (on Node < 22, use
* subscribe() long-polling instead, or attach the `ws` package to globalThis.WebSocket). The
* connection authenticates with a query key, because WebSocket cannot set headers.
*
* 與 stream 的差異(WebSocket vs EventSource):
* - **WebSocket 無原生自動重連**(EventSource 有),故重連一律由 SDK 接管:每次斷線退避 1s 重連,
* 成功連上即重置失敗計數;**兩模式皆對「連續」失敗(未曾連上)設 MAX_AUTH_RETRIES 上限**——達上限
* 停止,避免對已撤銷憑證/持續不可用端點無限重連。getToken 模式另在重連前換新 token。
* - **撤權**:連線中撤權為 CLOSE 1008(PolicyViolation)+ reason `"authorization revoked"`(SSE 用具名
* msgmesh-close 事件),SDK 見此終態立即停止。**握手期**撤權/失效是 HTTP 401 → CloseEvent 1006(非 1008),
* 無法只靠 reason 判別,由上述連續失敗上限收口。其他暫時性關閉(`"authorization check unavailable"`
* /網路中斷)退避重連。`stop()` 主動關閉不觸發 onError、並清除待觸發的重連 timer。
* **How it differs from stream() (WebSocket vs EventSource):**
* - **WebSocket has no native auto-reconnect** (EventSource does), so the SDK owns reconnect
* entirely: back off 1s after each drop, reset the failure counter on a successful connect,
* and — **in both auth modes** — cap *consecutive* failures (never having connected) at
* MAX_AUTH_RETRIES, then stop. That prevents reconnecting forever to a revoked credential or
* a persistently unavailable endpoint. In getToken mode it also rotates the token first.
* - **Revocation.** Mid-connection revocation arrives as CLOSE 1008 (PolicyViolation) with
* reason `"authorization revoked"` (SSE uses the named msgmesh-close event instead); the SDK
* treats it as terminal and stops at once. **During the handshake**, revocation/invalidity is
* an HTTP 401 → CloseEvent **1006**, not 1008, so the reason alone cannot identify it — that
* case is closed out by the consecutive-failure cap above. Other transient closes
* (`"authorization check unavailable"`, network drops) back off and reconnect. `stop()` closes
* deliberately: it does not fire onError, and it clears any pending reconnect timer.
*
* onMessage 收到每則事件的文字內容(訊息 value 字串);onError 收到 Event(連線錯誤)或 CloseEvent
* (關閉,可讀 e.code / e.reason)。
* `onMessage` receives each event's text content (the message value string); `onError` receives
* an `Event` (connection error) or a `CloseEvent` (closed — read `e.code` / `e.reason`).
*
* 續傳與去重(#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 原樣投遞、不去重(不誤丟)。
* **Resume and dedupe (#47 stage-2, mirroring stream()).** streamWs **always** connects with
* `?resume=1`, entering envelope mode (strict resume; SSE already defaults to it). The server
* wraps each data message as `{"id":"<partition>-<offset>","data":"<value>"}`; the SDK unwraps it
* transparently and hands the raw value to onMessage (a missing `data` = empty payload = empty
* string) — **the signature is unchanged**. The SDK remembers the last id seen and appends
* `&from=<id>` when reconnecting; the server seeks back, backfills what was missed, then rejoins
* live. Because delivery is **at-least-once** (the overlap window resends messages), the SDK
* always **dedupes per partition by id** (skipping any offset <= the highest seen for that
* partition) before calling onMessage.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* recorded before the last `stop()`); sent as `&from=` on the very first connect.
* - `opts.onResync` (optional): fires when the server sends the in-band control envelope
* `{"event":"msgmesh-resync"}` — its replay window could not cover the gap, so completeness is
* not guaranteed. The SDK clears the resume cursor and dedupe state; the caller should
* **re-fetch a history snapshot itself** (the SDK does not manage history). Later live
* messages dedupe by id against "snapshot + live". Revocation still travels as CLOSE 1008
* (above), never through an envelope.
* - Backward compatibility / safety valve: a **non-envelope** message (JSON without id/event, or
* not JSON at all) fails open — delivered as-is, not deduped, so nothing is wrongly dropped.
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。
* ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。
* `opts.room` (optional, multi-room routing): same as stream() — receive only messages whose
* Kafka record key equals `room`; omit it to receive everything.
* ⚠️ Routing filter only; the server does not enforce isolation (see stream()'s notes).
*/

@@ -730,3 +780,3 @@ streamWs(topic, onMessage, onError, opts) {

throw new Error(
"msgmesh: streamWs \u9700\u8981\u5168\u57DF WebSocket(\u700F\u89BD\u5668\u6216 Node \u2265 22)\u3002Node < 22 \u8ACB\u6539\u7528 subscribe()(\u9577\u8F2A\u8A62),\u6216\u5C07 ws \u5957\u4EF6\u639B\u5230 globalThis.WebSocket\u3002"
"msgmesh: streamWs requires a global WebSocket (a browser, or Node >= 22). On Node < 22 use subscribe() (long-polling) instead, or attach the `ws` package to globalThis.WebSocket."
);

@@ -751,3 +801,3 @@ }

console.warn(
"msgmesh: streamWs \u5DF2\u505C\u6B62 \u2014 \u9023\u7E8C\u591A\u6B21\u9023\u7DDA\u5931\u6557(\u6191\u8B49\u5DF2\u64A4\u92B7\u6216\u7AEF\u9EDE\u6301\u7E8C\u4E0D\u53EF\u7528)\u3002\u8ACB\u65BC\u6062\u5FA9\u5F8C\u91CD\u65B0\u547C\u53EB streamWs()\u3002"
"msgmesh: streamWs stopped \u2014 repeated connection failures (the credential was revoked, or the endpoint is persistently unavailable). Call streamWs() again once it recovers."
);

@@ -754,0 +804,0 @@ return;

interface MsgMeshOptions {
/** 長期 API key(機器/伺服器端用)。瀏覽器/不可信端勿放 apiKey,改用 getToken。 */
/** Long-lived API key (server-side / machine use). Never put an apiKey in a browser or any untrusted client — use getToken instead. */
apiKey?: string;
/**
* 取得短期資料面 token 的 callback(瀏覽器/不可信端用):由你的後端(持金鑰)代呼
* `POST /v1/tokens` 後回傳。SDK 會自動快取、於將過期前重取,SSE 重連時亦取新 token,
* 故「5 分鐘」對使用端是隱形的。與 apiKey 二擇一,至少需其一。
* Callback that fetches a short-lived data-plane token (for browsers / untrusted
* clients): your backend holds the key, calls `POST /v1/tokens`, and returns the
* result. The SDK caches it, refetches before expiry, and fetches a fresh token on
* SSE reconnect — so the "5 minutes" is invisible to your code. Provide either this
* or apiKey; at least one is required.
*/

@@ -15,3 +17,3 @@ getToken?: () => Promise<TokenResponse>;

}
/** TokenResponse 為 getToken 的回傳:短期 token + 可選存活秒數(對應 /v1/tokens 回應)。 */
/** What getToken returns: a short-lived token plus an optional lifetime in seconds (mirrors the /v1/tokens response). */
interface TokenResponse {

@@ -27,3 +29,3 @@ token: string;

name?: string;
/** 實際資料面能力:能力鍵回顯式集合;角色鍵(producer/consumer/admin)省略,由 scope 衍生。 */
/** Effective data-plane capabilities. Capability keys return an explicit set; role keys (producer/consumer/admin) omit it and derive it from scope. */
capabilities?: Array<{

@@ -34,5 +36,5 @@ ops: string[];

}
/** Settings 為租戶層級設定。 */
/** Tenant-level settings. */
interface Settings {
/** 資料面 topic 閘門:開啟後發/收/SSE/WS 只在已建立的 topic 上放行(未建回 404)。 */
/** Data-plane topic gate: once on, publish/consume/SSE/WS are allowed only on topics that already exist (404 otherwise). */
strict_topics: boolean;

@@ -49,7 +51,7 @@ }

included_msgs: number;
/** 達 included_msgs 即 429(free 硬上限;payg=false 無上限,純用量計費)。 */
/** Reaching included_msgs returns 429 (a hard cap on free; payg has hard_cap=false — no cap, pure usage billing). */
hard_cap: boolean;
/** 單 topic 並發即時訂閱(SSE/WS)上限,0=無限。 */
/** Max concurrent realtime subscribers (SSE/WS) per topic; 0 = unlimited. */
max_subscribers_per_topic: number;
/** 單租戶跨所有 topic 的即時連線總上限,0=無限。 */
/** Max realtime connections per tenant across all topics; 0 = unlimited. */
max_connections_per_tenant: number;

@@ -63,3 +65,3 @@ }

status: string;
/** 僅 status="suspended" 時有值:為何被停權(目前為 URL 未過 SSRF 防護)與何時。 */
/** Set only when status="suspended": why it was suspended (currently: the URL failed SSRF protection) and when. */
suspend_reason?: string;

@@ -75,3 +77,3 @@ suspended_at?: string;

}
/** TopicFunction 是綁定在 topic 上的訊息轉換函數(避免與全域 Function 型別衝突,故不取名 Function)。 */
/** A message-transform function bound to a topic. Not named `Function` so it does not clash with the global type. */
interface TopicFunction {

@@ -82,7 +84,7 @@ id: string;

code: string;
/** javascript(預設,goja JS 沙箱)| wasm(WASI 模組,code 為 base64 二進位)。 */
/** javascript (default; goja JS sandbox) | wasm (WASI module; `code` is the base64 binary). */
language: string;
enabled: boolean;
}
/** Presence 是 topic 目前線上連線數(realtime 服務)。 */
/** Current online connection count for a topic (from the realtime service). */
interface Presence {

@@ -111,3 +113,3 @@ online: number;

}
/** Page 是 billing 列表端點的泛型分頁包裝:items + opaque next_cursor(空字串=末頁)。 */
/** Generic pagination envelope for the billing list endpoints: items + an opaque next_cursor (empty string = last page). */
interface Page<T> {

@@ -117,3 +119,3 @@ items: T[];

}
/** Billing 是 GET /v1/billing 的回應:預付餘額 + 計費狀態 + 續航(供面板餘額卡/橫幅/充值 CTA)。 */
/** The GET /v1/billing response: prepaid balance, billing status, and runway (drives the panel's balance card, banner, and top-up CTA). */
interface Billing {

@@ -125,6 +127,6 @@ balance_micros: string;

low_balance: boolean;
/** 餘額 / 近日均花(向下取整);-1=未知/無限。 */
/** Balance divided by recent average daily spend (floored); -1 = unknown / unlimited. */
runway_days: number;
}
/** DepositAddress 是某鏈 watch-only 收款地址;qr_payload 為純地址(QR 編碼用)。 */
/** A watch-only receiving address on one chain; qr_payload is the bare address (for QR encoding). */
interface DepositAddress {

@@ -138,3 +140,3 @@ /** tron | ethereum */

}
/** Deposit 是一筆鏈上 USDT 入帳;confirmations 為後端估算進度(非權威),credited 後等於 required。 */
/** One on-chain USDT deposit. `confirmations` is the server's progress estimate (not authoritative); once credited it equals `required_confirmations`. */
interface Deposit {

@@ -153,3 +155,3 @@ id: string;

}
/** LedgerEntry 是一筆帳本流水(加值/用量扣款/手動調整/退款)。 */
/** One ledger entry (top-up / usage debit / manual adjustment / refund). */
interface LedgerEntry {

@@ -165,3 +167,3 @@ id: string;

}
/** UsageDebit 是一筆每日用量扣款。 */
/** One daily usage debit. */
interface UsageDebit {

@@ -174,3 +176,3 @@ day: string;

}
/** DepositStatus 是 tx_hash 自助查詢結果;未命中 found=false、status=unrecognized。 */
/** Self-service lookup result for a tx_hash; a miss returns found=false and status=unrecognized. */
interface DepositStatus {

@@ -188,3 +190,3 @@ found: boolean;

}
/** FinanceChainAmount 是某鏈的已入帳金額彙總(overview 各鏈 credited 總額)。 */
/** Total credited amount for one chain (the per-chain credited totals in the overview). */
interface FinanceChainAmount {

@@ -196,3 +198,3 @@ /** tron | ethereum */

}
/** FinanceOverview 是 GET /admin/finance/overview:平台總餘額、各鏈已入帳總額、各 billing_status 計數。 */
/** GET /admin/finance/overview: platform-wide balance, credited totals per chain, and tenant counts per billing_status. */
interface FinanceOverview {

@@ -202,6 +204,6 @@ total_balance_micros: string;

credited_deposits: FinanceChainAmount[];
/** key=billing_status(active|low|grace|suspended),value=租戶數。 */
/** key = billing_status (active|low|grace|suspended), value = tenant count. */
billing_status_counts: Record<string, number>;
}
/** FinanceTenant 是 GET /admin/finance/tenants 的一列租戶金流概況(餘額/計費狀態/續航)。 */
/** One row of GET /admin/finance/tenants: a tenant's balance, billing status, and runway. */
interface FinanceTenant {

@@ -216,6 +218,6 @@ id: string;

balance_usd: number;
/** 餘額 / 近日均花(向下取整);-1=未知/無限。 */
/** Balance divided by recent average daily spend (floored); -1 = unknown / unlimited. */
runway_days: number;
}
/** AdjustResult 是 POST /admin/finance/tenants/{id}/adjust 的結果;replayed=true 表此 idempotency_key 先前已套用。 */
/** Result of POST /admin/finance/tenants/{id}/adjust; replayed=true means this idempotency_key was already applied earlier. */
interface AdjustResult {

@@ -238,3 +240,3 @@ ledger_id: string;

}
/** ReplayResult 是 DLQ 重放結果:replayed=本次重放回主 topic 的筆數;has_more=DLQ 是否仍有可重放訊息(true 表可再次呼叫續放)。 */
/** DLQ replay result: `replayed` is how many messages this call pushed back to the main topic; `has_more` says whether the DLQ still holds replayable messages (true = call again to continue). */
interface ReplayResult {

@@ -261,4 +263,5 @@ replayed: number;

/**
* SDK 統一錯誤型別:後端回非 2xx 時依狀態碼拋對應子類,
* 呼叫端可用 instanceof 區分驗證 / 鑑權 / 不存在 / 限流。
* The SDK's unified error type. A non-2xx response throws the subclass matching the
* status code, so callers can tell validation / auth / not-found / rate-limit apart
* with `instanceof`.
*/

@@ -270,4 +273,5 @@ declare class MsgMeshError extends Error {

/**
* 後端為此回應產生的追蹤 ID(取自 X-Request-Id header,或回應 body 的 request_id)。
* 已併入 error message 末尾(` [request_id: xxx]`),回報問題時附上可加速定位。
* The trace ID the server generated for this response (from the `X-Request-Id`
* header, or `request_id` in the body). It is appended to the error message
* (` [request_id: xxx]`) — include it when reporting a problem to speed up triage.
*/

@@ -277,24 +281,25 @@ readonly requestId?: string;

}
/** 400/422:請求參數或內容不合法。 */
/** 400/422 — the request's arguments or body are invalid. */
declare class ValidationError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** 401/403:API Key 無效、scope 不足或方案不允許。 */
/** 401/403 — the API key is invalid, its scope is insufficient, or the plan disallows it. */
declare class AuthError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** 404:資源不存在。 */
/** 404 — the resource does not exist. */
declare class NotFoundError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** 429:超出速率限制,稍後重試。 */
/** 429 — rate limit exceeded; retry later. */
declare class RateLimitError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** errorFromStatus 依 HTTP 狀態碼建立對應的錯誤型別。 */
/** Builds the error type matching an HTTP status code. */
declare function errorFromStatus(status: number, path: string, message: string, requestId?: string): MsgMeshError;
/**
* errorFromResponse 解析後端 {"error": msg} 形狀(非 JSON 時用原始文字)後建錯誤。
* 追蹤 ID 優先取 X-Request-Id header,退而取 body 的 request_id(對齊後端 500 契約
* {"error":"internal server error","request_id":"<id>"});兩者皆無則留空。
* Parses the server's {"error": msg} shape (falling back to the raw text when the body
* is not JSON) and builds the error. The trace ID prefers the `X-Request-Id` header and
* falls back to `request_id` in the body (matching the server's 500 contract
* {"error":"internal server error","request_id":"<id>"}); left unset when neither exists.
*/

@@ -306,3 +311,3 @@ declare function errorFromResponse(res: Response): Promise<MsgMeshError>;

private getToken?;
/** 短期 token 快取(getToken 模式):值 + 到期時間(epoch ms);refreshing 防併發重取(thundering herd)。 */
/** Short-lived token cache (getToken mode): the value plus its expiry (epoch ms). `refreshing` collapses concurrent refetches into one (thundering herd). */
private cachedToken?;

@@ -315,23 +320,33 @@ private tokenExpiry;

private fetchImpl;
/** 未明確指定的服務 URL(base → 選項名),連線失敗時提示使用者設定。 */
/** Service URLs left unset (base → option name), used to tell the caller which one to configure when a connection fails. */
private defaulted;
constructor(opts: MsgMeshOptions);
/**
* credential 回傳目前的鑑權憑證:apiKey 模式回 apiKey;getToken 模式回快取的短期 token,
* 將過期(含 skew)時自動透過 getToken 重取。refreshing 確保併發呼叫只觸發一次重取。
* Returns the current credential: the apiKey in apiKey mode; in getToken mode, the cached
* short-lived token, refetched via getToken once it is about to expire (including skew).
* `refreshing` guarantees concurrent callers trigger only one refetch.
*/
private credential;
/** invalidateToken 使快取 token 失效,強制下次重取(getToken 模式下遇 401 時用)。 */
/** Invalidates the cached token so the next call refetches (used on a 401 in getToken mode). */
private invalidateToken;
private authHeader;
/**
* f 發出已鑑權請求。getToken 模式下若收到 401(快取 token 過期/被拒:時鐘偏移、伺服器側 TTL 較短、
* 簽章輪替),失效快取並以新 token 重試一次(401 表示請求未被處理,重試安全,含 POST/DELETE)。
* 連線層錯誤(非 HTTP)由 attempt 包裝設定提示後拋出。
* Issues an authenticated request. In getToken mode a 401 (cached token expired or rejected —
* clock skew, a shorter server-side TTL, signing-key rotation) invalidates the cache and
* retries once with a fresh token. A 401 means the request was never processed, so retrying is
* safe even for POST/DELETE. Connection-level errors (not HTTP) are thrown by `attempt` with a
* configuration hint attached.
*/
private f;
/** attempt 包一層 fetch:用預設 URL 連線失敗時,在錯誤訊息附設定提示。 */
/** Wraps fetch: when a connection to a *defaulted* URL fails, append a configuration hint to the error. */
private attempt;
createTopic(name: string, partitions?: number): Promise<Topic>;
listTopics(): Promise<Topic[]>;
/**
* Deletes a topic. **This destroys data irreversibly**: the topic's messages, its dead-letter
* queue (`.dlq`), schema versions, transform function, and webhook bindings are all removed —
* recreating the same name only gets you a brand-new empty topic.
* Idempotent: deleting a topic that does not exist is not an error. If cleanup is incomplete
* it throws rather than pretending to succeed, so you can simply retry.
*/
deleteTopic(name: string): Promise<void>;

@@ -346,7 +361,10 @@ publish(topic: string, body: unknown, opts?: {

/**
* subscribe 持續輪詢,回傳停止函式。
* opts.onError(可選):每次輪詢出錯時回報。
* 終態 vs 可恢復:**只有 401(金鑰失效/不存在=終態)才永久停止重試**——不再無聲無限地拿失效金鑰重打;
* 403(可能為治理停權 suspended,可自助充值解封→可恢復)與其他暫時性錯誤一律回報後退避續試(維持自癒)。
* 永久停止(401)時若呼叫端未提供 onError,會 console.warn 一行(說明已停、原因、建議提供 onError),避免靜默死掉。
* Polls continuously; returns a stop function.
* `opts.onError` (optional) is called on every failed poll.
* Terminal vs recoverable: **only a 401 (key invalid or gone = terminal) stops retrying for
* good** — no more silently hammering forever with a dead key. A 403 (possibly a governance
* suspension, which the tenant can lift by topping up = recoverable) and any other transient
* error are reported and then retried with backoff, so the subscription still self-heals.
* When it stops permanently and the caller supplied no onError, it emits a single
* console.warn (what stopped, why, and to pass onError) rather than dying silently.
*/

@@ -360,10 +378,12 @@ subscribe(topic: string, opts: {

getSnippet(): Promise<string>;
/** getDocs 取得由租戶 topics+schema 生成的 Markdown 使用文件。 */
/** Fetches Markdown usage docs generated from this tenant's topics and schemas. */
getDocs(): Promise<string>;
listKeys(): Promise<APIKey[]>;
/**
* 簽發 API key。省略 capabilities → 角色鍵(scope=admin|producer|consumer);
* 提供 capabilities → 細粒度能力鍵(scope 須為 producer/consumer,非 admin),
* 形狀 [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }]。
* rooms 選用(房間隔離):省略/空 = 所有房間;非空 = 僅限這些房間(發佈的 ?key / 訂閱的 ?room),平台強制。
* Issues an API key. Omit `capabilities` for a role key (scope = admin|producer|consumer);
* supply `capabilities` for a fine-grained capability key (scope must be producer/consumer,
* not admin), shaped as
* [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }].
* `rooms` is optional (room isolation): omitted or empty = all rooms; non-empty = only these
* rooms — enforced by the platform on publish (`?key`) and subscribe (`?room`).
*/

@@ -385,21 +405,23 @@ createKey(scope: string, opts?: {

getAudit(limit?: number): Promise<AuditEntry[]>;
/** getBilling 查詢預付餘額 + 計費狀態 + 續航(加密貨幣 PAYG 帳本)。 */
/** Fetches prepaid balance, billing status, and runway (the crypto PAYG ledger). */
getBilling(): Promise<Billing>;
/** getDepositAddresses 取各鏈 watch-only 收款地址(QR / 複製用)。 */
/** Fetches the watch-only receiving address for each chain (for QR display / copy-paste). */
getDepositAddresses(): Promise<DepositAddress[]>;
/** getDeposits 列出鏈上 USDT 入帳(分頁:cursor 取自前一頁 next_cursor)。 */
/** Lists on-chain USDT deposits (paginated: `cursor` comes from the previous page's next_cursor). */
getDeposits(cursor?: string, limit?: number): Promise<Page<Deposit>>;
/** getLedger 列出帳本流水(分頁)。 */
/** Lists ledger entries (paginated). */
getLedger(cursor?: string, limit?: number): Promise<Page<LedgerEntry>>;
/** getUsageDebits 列出每日用量扣款(分頁)。 */
/** Lists daily usage debits (paginated). */
getUsageDebits(cursor?: string, limit?: number): Promise<Page<UsageDebit>>;
/** getDepositStatus 自助查某 tx_hash 的入帳狀態(限本租戶;未命中回 found=false)。 */
/** Self-service lookup of a tx_hash's credit status (this tenant only; a miss returns found=false). */
getDepositStatus(txHash: string): Promise<DepositStatus>;
getPlan(): Promise<PlanLimits>;
setPlan(plan: string): Promise<PlanLimits>;
/** getSettings 取得租戶層級設定(目前:strict_topics)。 */
/** Fetches tenant-level settings (currently just strict_topics). */
getSettings(): Promise<Settings>;
/**
* setStrictTopics 開/關資料面 topic 閘門。開啟後,發/收/SSE/WS 到「未由控制面建立」的 topic 一律 404
*(堵住憑空開 topic、繞過配額/分區/schema);關閉(預設)維持鬆散。
* Turns the data-plane topic gate on or off. Once on, publishing / consuming / SSE / WS
* against a topic that was never created through the control plane returns 404 — closing the
* door on conjuring topics out of thin air and bypassing quota, partitioning, and schema.
* Off (the default) stays permissive.
*/

@@ -410,7 +432,8 @@ setStrictTopics(enabled: boolean): Promise<Settings>;

deleteWebhook(id: string): Promise<void>;
/** reactivateWebhook 重新啟用被停權(如 URL 未過 SSRF 防護)的 webhook;查無此 webhook 會拋 NotFoundError。 */
/** Reactivates a suspended webhook (e.g. one whose URL failed SSRF protection); throws NotFoundError if it does not exist. */
reactivateWebhook(id: string): Promise<void>;
/**
* registerFunction 為 topic 註冊轉換函數。language 預設 javascript(goja JS 沙箱);
* 傳 "wasm" 則 code 須為 base64 編碼的 WASI 模組二進位(讀 stdin JSON、寫 stdout JSON)。
* Registers a transform function on a topic. `language` defaults to javascript (goja JS
* sandbox); pass "wasm" and `code` must be the base64-encoded WASI module binary (which
* reads JSON on stdin and writes JSON to stdout).
*/

@@ -423,3 +446,3 @@ registerFunction(topic: string, code: string, language?: string): Promise<TopicFunction>;

getLatestSchema(topic: string): Promise<SchemaVersion>;
/** deleteSchema 刪除 topic 的指定 schema 版本(不可刪 latest:409;無此版:404)。 */
/** Deletes one schema version from a topic (409 if it is the latest — that one cannot be deleted; 404 if the version does not exist). */
deleteSchema(topic: string, version: number): Promise<void>;

@@ -431,38 +454,52 @@ dlqPeek(topic: string, opts?: {

/**
* dlqReplay 將 DLQ 訊息重放回主 topic。回傳 replayed(本次重放筆數)與 has_more
* (DLQ 是否仍有可重放訊息;true 表可再次呼叫續放,用於分批清空大量 DLQ)。
* Replays DLQ messages back onto the main topic. Returns `replayed` (how many this call moved)
* and `has_more` (whether the DLQ still holds replayable messages; true means call again to
* continue — useful for draining a large DLQ in batches).
*/
dlqReplay(topic: string, max?: number): Promise<ReplayResult>;
/**
* getPresence 查詢 topic 目前線上連線數(realtime 服務)。
* 鑑權走 Authorization header(非 query key):此為一般 fetch,可設 header,避免把 API Key
* 放進 query→realtime 存取日誌。僅 stream()(EventSource 無法設 header)才用 query key。
* Fetches a topic's current online connection count (from the realtime service).
* Authenticates with an Authorization header, not a query key: this is an ordinary fetch so a
* header is possible, which keeps the API key out of the query string — and therefore out of
* realtime's access log. Only stream() uses a query key, because EventSource cannot set headers.
*/
getPresence(topic: string): Promise<Presence>;
/**
* stream 透過 SSE 即時接收訊息(僅瀏覽器:依賴 EventSource)。回傳停止函式。
* 連線鑑權走 query key(瀏覽器無法設 Authorization header)。
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a
* stop function. The connection authenticates with a query key, because a browser cannot set
* an Authorization header on an EventSource.
*
* onError 會收到兩類事件:
* - EventSource 連線錯誤(Event)。
* - 伺服器具名控制事件 msgmesh-close(MessageEvent,e.data 為原因字串)。
* `"authorization revoked"`=撤權(終態),SDK 主動停止重連,呼叫端應提示重新登入。
* `onError` receives two kinds of event:
* - An EventSource connection error (`Event`).
* - The server's named control event `msgmesh-close` (`MessageEvent`, reason in `e.data`).
* `"authorization revoked"` is terminal: the SDK stops reconnecting on its own, and the
* caller should prompt the user to sign in again.
*
* 重連:apiKey 模式交 EventSource 原生重連(撤權靠 msgmesh-close 收口)。getToken 模式由 SDK 接管——
* 任何連線錯誤都以「新 token」重連(失效快取),避免拿過期 token 死循環;並對「連續」失敗設上限
* (MAX_AUTH_RETRIES),達上限視為永久撤權而停止(一次成功連上即歸零),以免對已撤權者無限重連。
* **Reconnect.** In apiKey mode, EventSource's native reconnect handles it (revocation is closed
* out by `msgmesh-close`). In getToken mode the SDK takes over: every connection error
* reconnects with a *fresh* token (the cache is invalidated), so an expired token can never spin
* forever; and *consecutive* failures are capped (MAX_AUTH_RETRIES), after which it treats the
* credential as permanently revoked and stops. Any successful connect resets the counter.
*
* 續傳與去重(#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」
* 自去重。
* **Resume and dedupe (#16).** Every data message carries an `id:<partition>-<offset>` cursor.
* The SDK remembers the last id it saw and sends it back on reconnect (getToken mode manages its
* own reconnect and appends `&from=<id>`; apiKey mode relies on EventSource's native
* `Last-Event-ID` header). The server seeks back, backfills what was missed while disconnected,
* then rejoins live. Because delivery is **at-least-once** (the overlap window resends
* messages), the SDK always **dedupes per partition by id** — skipping any offset <= the highest
* already seen for that partition — before calling `onMessage`. This holds in both modes.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* you recorded before the last `stop()`); sent as `&from=` on the very first connect. This is
* what makes resume work across `stop()` → `stream()` again.
* - `opts.onResync` (optional): fires when the server sends `msgmesh-resync` — its replay window
* could not cover the gap, so completeness is not guaranteed. The SDK clears the resume cursor
* and dedupe state; the caller should **re-fetch a history snapshot itself** (the SDK does not
* manage history). Later live messages dedupe by id against "snapshot + live" on their own.
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定
* 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的
* room 偷聽同 topic 其他房間;真隔離需 token 帶 room scope,MVP 階段隔離靠你的後端 token-broker + 誠實 client。
* `opts.room` (optional, multi-room routing): receive only messages whose Kafka record key
* equals `room` (publish targets a room via publish's `key`). Omitting it receives everything on
* the topic. ⚠️ `room` is only a routing filter — **the server does not enforce isolation**. A
* malicious client can switch to someone else's `room` and eavesdrop on other rooms in the same
* topic. Real isolation requires a token carrying a room scope; at the MVP stage, isolation
* rests on your backend token-broker plus an honest client.
*/

@@ -475,31 +512,48 @@ stream(topic: string, onMessage: (data: string) => void, onError?: (e: Event | MessageEvent) => void, opts?: {

/**
* streamWs 透過 WebSocket 即時接收訊息,介面與 stream(SSE)一致、回傳停止函式。用全域 `WebSocket`
* (瀏覽器原生;Node ≥ 22 亦內建全域 WebSocket)——環境無全域 WebSocket 時立即拋錯(Node < 22 請改用
* subscribe() 長輪詢,或把 ws 套件掛到 globalThis.WebSocket)。連線鑑權走 query key(WebSocket 無法設 header)。
* Receives messages in realtime over WebSocket. Same interface as stream() (SSE) and likewise
* returns a stop function. Uses the global `WebSocket` (native in browsers; also built into
* Node >= 22) — it throws immediately when no global WebSocket exists (on Node < 22, use
* subscribe() long-polling instead, or attach the `ws` package to globalThis.WebSocket). The
* connection authenticates with a query key, because WebSocket cannot set headers.
*
* 與 stream 的差異(WebSocket vs EventSource):
* - **WebSocket 無原生自動重連**(EventSource 有),故重連一律由 SDK 接管:每次斷線退避 1s 重連,
* 成功連上即重置失敗計數;**兩模式皆對「連續」失敗(未曾連上)設 MAX_AUTH_RETRIES 上限**——達上限
* 停止,避免對已撤銷憑證/持續不可用端點無限重連。getToken 模式另在重連前換新 token。
* - **撤權**:連線中撤權為 CLOSE 1008(PolicyViolation)+ reason `"authorization revoked"`(SSE 用具名
* msgmesh-close 事件),SDK 見此終態立即停止。**握手期**撤權/失效是 HTTP 401 → CloseEvent 1006(非 1008),
* 無法只靠 reason 判別,由上述連續失敗上限收口。其他暫時性關閉(`"authorization check unavailable"`
* /網路中斷)退避重連。`stop()` 主動關閉不觸發 onError、並清除待觸發的重連 timer。
* **How it differs from stream() (WebSocket vs EventSource):**
* - **WebSocket has no native auto-reconnect** (EventSource does), so the SDK owns reconnect
* entirely: back off 1s after each drop, reset the failure counter on a successful connect,
* and — **in both auth modes** — cap *consecutive* failures (never having connected) at
* MAX_AUTH_RETRIES, then stop. That prevents reconnecting forever to a revoked credential or
* a persistently unavailable endpoint. In getToken mode it also rotates the token first.
* - **Revocation.** Mid-connection revocation arrives as CLOSE 1008 (PolicyViolation) with
* reason `"authorization revoked"` (SSE uses the named msgmesh-close event instead); the SDK
* treats it as terminal and stops at once. **During the handshake**, revocation/invalidity is
* an HTTP 401 → CloseEvent **1006**, not 1008, so the reason alone cannot identify it — that
* case is closed out by the consecutive-failure cap above. Other transient closes
* (`"authorization check unavailable"`, network drops) back off and reconnect. `stop()` closes
* deliberately: it does not fire onError, and it clears any pending reconnect timer.
*
* onMessage 收到每則事件的文字內容(訊息 value 字串);onError 收到 Event(連線錯誤)或 CloseEvent
* (關閉,可讀 e.code / e.reason)。
* `onMessage` receives each event's text content (the message value string); `onError` receives
* an `Event` (connection error) or a `CloseEvent` (closed — read `e.code` / `e.reason`).
*
* 續傳與去重(#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 原樣投遞、不去重(不誤丟)。
* **Resume and dedupe (#47 stage-2, mirroring stream()).** streamWs **always** connects with
* `?resume=1`, entering envelope mode (strict resume; SSE already defaults to it). The server
* wraps each data message as `{"id":"<partition>-<offset>","data":"<value>"}`; the SDK unwraps it
* transparently and hands the raw value to onMessage (a missing `data` = empty payload = empty
* string) — **the signature is unchanged**. The SDK remembers the last id seen and appends
* `&from=<id>` when reconnecting; the server seeks back, backfills what was missed, then rejoins
* live. Because delivery is **at-least-once** (the overlap window resends messages), the SDK
* always **dedupes per partition by id** (skipping any offset <= the highest seen for that
* partition) before calling onMessage.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* recorded before the last `stop()`); sent as `&from=` on the very first connect.
* - `opts.onResync` (optional): fires when the server sends the in-band control envelope
* `{"event":"msgmesh-resync"}` — its replay window could not cover the gap, so completeness is
* not guaranteed. The SDK clears the resume cursor and dedupe state; the caller should
* **re-fetch a history snapshot itself** (the SDK does not manage history). Later live
* messages dedupe by id against "snapshot + live". Revocation still travels as CLOSE 1008
* (above), never through an envelope.
* - Backward compatibility / safety valve: a **non-envelope** message (JSON without id/event, or
* not JSON at all) fails open — delivered as-is, not deduped, so nothing is wrongly dropped.
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。
* ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。
* `opts.room` (optional, multi-room routing): same as stream() — receive only messages whose
* Kafka record key equals `room`; omit it to receive everything.
* ⚠️ Routing filter only; the server does not enforce isolation (see stream()'s notes).
*/

@@ -506,0 +560,0 @@ streamWs(topic: string, onMessage: (data: string) => void, onError?: (e: Event | CloseEvent) => void, opts?: {

interface MsgMeshOptions {
/** 長期 API key(機器/伺服器端用)。瀏覽器/不可信端勿放 apiKey,改用 getToken。 */
/** Long-lived API key (server-side / machine use). Never put an apiKey in a browser or any untrusted client — use getToken instead. */
apiKey?: string;
/**
* 取得短期資料面 token 的 callback(瀏覽器/不可信端用):由你的後端(持金鑰)代呼
* `POST /v1/tokens` 後回傳。SDK 會自動快取、於將過期前重取,SSE 重連時亦取新 token,
* 故「5 分鐘」對使用端是隱形的。與 apiKey 二擇一,至少需其一。
* Callback that fetches a short-lived data-plane token (for browsers / untrusted
* clients): your backend holds the key, calls `POST /v1/tokens`, and returns the
* result. The SDK caches it, refetches before expiry, and fetches a fresh token on
* SSE reconnect — so the "5 minutes" is invisible to your code. Provide either this
* or apiKey; at least one is required.
*/

@@ -15,3 +17,3 @@ getToken?: () => Promise<TokenResponse>;

}
/** TokenResponse 為 getToken 的回傳:短期 token + 可選存活秒數(對應 /v1/tokens 回應)。 */
/** What getToken returns: a short-lived token plus an optional lifetime in seconds (mirrors the /v1/tokens response). */
interface TokenResponse {

@@ -27,3 +29,3 @@ token: string;

name?: string;
/** 實際資料面能力:能力鍵回顯式集合;角色鍵(producer/consumer/admin)省略,由 scope 衍生。 */
/** Effective data-plane capabilities. Capability keys return an explicit set; role keys (producer/consumer/admin) omit it and derive it from scope. */
capabilities?: Array<{

@@ -34,5 +36,5 @@ ops: string[];

}
/** Settings 為租戶層級設定。 */
/** Tenant-level settings. */
interface Settings {
/** 資料面 topic 閘門:開啟後發/收/SSE/WS 只在已建立的 topic 上放行(未建回 404)。 */
/** Data-plane topic gate: once on, publish/consume/SSE/WS are allowed only on topics that already exist (404 otherwise). */
strict_topics: boolean;

@@ -49,7 +51,7 @@ }

included_msgs: number;
/** 達 included_msgs 即 429(free 硬上限;payg=false 無上限,純用量計費)。 */
/** Reaching included_msgs returns 429 (a hard cap on free; payg has hard_cap=false — no cap, pure usage billing). */
hard_cap: boolean;
/** 單 topic 並發即時訂閱(SSE/WS)上限,0=無限。 */
/** Max concurrent realtime subscribers (SSE/WS) per topic; 0 = unlimited. */
max_subscribers_per_topic: number;
/** 單租戶跨所有 topic 的即時連線總上限,0=無限。 */
/** Max realtime connections per tenant across all topics; 0 = unlimited. */
max_connections_per_tenant: number;

@@ -63,3 +65,3 @@ }

status: string;
/** 僅 status="suspended" 時有值:為何被停權(目前為 URL 未過 SSRF 防護)與何時。 */
/** Set only when status="suspended": why it was suspended (currently: the URL failed SSRF protection) and when. */
suspend_reason?: string;

@@ -75,3 +77,3 @@ suspended_at?: string;

}
/** TopicFunction 是綁定在 topic 上的訊息轉換函數(避免與全域 Function 型別衝突,故不取名 Function)。 */
/** A message-transform function bound to a topic. Not named `Function` so it does not clash with the global type. */
interface TopicFunction {

@@ -82,7 +84,7 @@ id: string;

code: string;
/** javascript(預設,goja JS 沙箱)| wasm(WASI 模組,code 為 base64 二進位)。 */
/** javascript (default; goja JS sandbox) | wasm (WASI module; `code` is the base64 binary). */
language: string;
enabled: boolean;
}
/** Presence 是 topic 目前線上連線數(realtime 服務)。 */
/** Current online connection count for a topic (from the realtime service). */
interface Presence {

@@ -111,3 +113,3 @@ online: number;

}
/** Page 是 billing 列表端點的泛型分頁包裝:items + opaque next_cursor(空字串=末頁)。 */
/** Generic pagination envelope for the billing list endpoints: items + an opaque next_cursor (empty string = last page). */
interface Page<T> {

@@ -117,3 +119,3 @@ items: T[];

}
/** Billing 是 GET /v1/billing 的回應:預付餘額 + 計費狀態 + 續航(供面板餘額卡/橫幅/充值 CTA)。 */
/** The GET /v1/billing response: prepaid balance, billing status, and runway (drives the panel's balance card, banner, and top-up CTA). */
interface Billing {

@@ -125,6 +127,6 @@ balance_micros: string;

low_balance: boolean;
/** 餘額 / 近日均花(向下取整);-1=未知/無限。 */
/** Balance divided by recent average daily spend (floored); -1 = unknown / unlimited. */
runway_days: number;
}
/** DepositAddress 是某鏈 watch-only 收款地址;qr_payload 為純地址(QR 編碼用)。 */
/** A watch-only receiving address on one chain; qr_payload is the bare address (for QR encoding). */
interface DepositAddress {

@@ -138,3 +140,3 @@ /** tron | ethereum */

}
/** Deposit 是一筆鏈上 USDT 入帳;confirmations 為後端估算進度(非權威),credited 後等於 required。 */
/** One on-chain USDT deposit. `confirmations` is the server's progress estimate (not authoritative); once credited it equals `required_confirmations`. */
interface Deposit {

@@ -153,3 +155,3 @@ id: string;

}
/** LedgerEntry 是一筆帳本流水(加值/用量扣款/手動調整/退款)。 */
/** One ledger entry (top-up / usage debit / manual adjustment / refund). */
interface LedgerEntry {

@@ -165,3 +167,3 @@ id: string;

}
/** UsageDebit 是一筆每日用量扣款。 */
/** One daily usage debit. */
interface UsageDebit {

@@ -174,3 +176,3 @@ day: string;

}
/** DepositStatus 是 tx_hash 自助查詢結果;未命中 found=false、status=unrecognized。 */
/** Self-service lookup result for a tx_hash; a miss returns found=false and status=unrecognized. */
interface DepositStatus {

@@ -188,3 +190,3 @@ found: boolean;

}
/** FinanceChainAmount 是某鏈的已入帳金額彙總(overview 各鏈 credited 總額)。 */
/** Total credited amount for one chain (the per-chain credited totals in the overview). */
interface FinanceChainAmount {

@@ -196,3 +198,3 @@ /** tron | ethereum */

}
/** FinanceOverview 是 GET /admin/finance/overview:平台總餘額、各鏈已入帳總額、各 billing_status 計數。 */
/** GET /admin/finance/overview: platform-wide balance, credited totals per chain, and tenant counts per billing_status. */
interface FinanceOverview {

@@ -202,6 +204,6 @@ total_balance_micros: string;

credited_deposits: FinanceChainAmount[];
/** key=billing_status(active|low|grace|suspended),value=租戶數。 */
/** key = billing_status (active|low|grace|suspended), value = tenant count. */
billing_status_counts: Record<string, number>;
}
/** FinanceTenant 是 GET /admin/finance/tenants 的一列租戶金流概況(餘額/計費狀態/續航)。 */
/** One row of GET /admin/finance/tenants: a tenant's balance, billing status, and runway. */
interface FinanceTenant {

@@ -216,6 +218,6 @@ id: string;

balance_usd: number;
/** 餘額 / 近日均花(向下取整);-1=未知/無限。 */
/** Balance divided by recent average daily spend (floored); -1 = unknown / unlimited. */
runway_days: number;
}
/** AdjustResult 是 POST /admin/finance/tenants/{id}/adjust 的結果;replayed=true 表此 idempotency_key 先前已套用。 */
/** Result of POST /admin/finance/tenants/{id}/adjust; replayed=true means this idempotency_key was already applied earlier. */
interface AdjustResult {

@@ -238,3 +240,3 @@ ledger_id: string;

}
/** ReplayResult 是 DLQ 重放結果:replayed=本次重放回主 topic 的筆數;has_more=DLQ 是否仍有可重放訊息(true 表可再次呼叫續放)。 */
/** DLQ replay result: `replayed` is how many messages this call pushed back to the main topic; `has_more` says whether the DLQ still holds replayable messages (true = call again to continue). */
interface ReplayResult {

@@ -261,4 +263,5 @@ replayed: number;

/**
* SDK 統一錯誤型別:後端回非 2xx 時依狀態碼拋對應子類,
* 呼叫端可用 instanceof 區分驗證 / 鑑權 / 不存在 / 限流。
* The SDK's unified error type. A non-2xx response throws the subclass matching the
* status code, so callers can tell validation / auth / not-found / rate-limit apart
* with `instanceof`.
*/

@@ -270,4 +273,5 @@ declare class MsgMeshError extends Error {

/**
* 後端為此回應產生的追蹤 ID(取自 X-Request-Id header,或回應 body 的 request_id)。
* 已併入 error message 末尾(` [request_id: xxx]`),回報問題時附上可加速定位。
* The trace ID the server generated for this response (from the `X-Request-Id`
* header, or `request_id` in the body). It is appended to the error message
* (` [request_id: xxx]`) — include it when reporting a problem to speed up triage.
*/

@@ -277,24 +281,25 @@ readonly requestId?: string;

}
/** 400/422:請求參數或內容不合法。 */
/** 400/422 — the request's arguments or body are invalid. */
declare class ValidationError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** 401/403:API Key 無效、scope 不足或方案不允許。 */
/** 401/403 — the API key is invalid, its scope is insufficient, or the plan disallows it. */
declare class AuthError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** 404:資源不存在。 */
/** 404 — the resource does not exist. */
declare class NotFoundError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** 429:超出速率限制,稍後重試。 */
/** 429 — rate limit exceeded; retry later. */
declare class RateLimitError extends MsgMeshError {
constructor(status: number, path: string, message: string, requestId?: string);
}
/** errorFromStatus 依 HTTP 狀態碼建立對應的錯誤型別。 */
/** Builds the error type matching an HTTP status code. */
declare function errorFromStatus(status: number, path: string, message: string, requestId?: string): MsgMeshError;
/**
* errorFromResponse 解析後端 {"error": msg} 形狀(非 JSON 時用原始文字)後建錯誤。
* 追蹤 ID 優先取 X-Request-Id header,退而取 body 的 request_id(對齊後端 500 契約
* {"error":"internal server error","request_id":"<id>"});兩者皆無則留空。
* Parses the server's {"error": msg} shape (falling back to the raw text when the body
* is not JSON) and builds the error. The trace ID prefers the `X-Request-Id` header and
* falls back to `request_id` in the body (matching the server's 500 contract
* {"error":"internal server error","request_id":"<id>"}); left unset when neither exists.
*/

@@ -306,3 +311,3 @@ declare function errorFromResponse(res: Response): Promise<MsgMeshError>;

private getToken?;
/** 短期 token 快取(getToken 模式):值 + 到期時間(epoch ms);refreshing 防併發重取(thundering herd)。 */
/** Short-lived token cache (getToken mode): the value plus its expiry (epoch ms). `refreshing` collapses concurrent refetches into one (thundering herd). */
private cachedToken?;

@@ -315,23 +320,33 @@ private tokenExpiry;

private fetchImpl;
/** 未明確指定的服務 URL(base → 選項名),連線失敗時提示使用者設定。 */
/** Service URLs left unset (base → option name), used to tell the caller which one to configure when a connection fails. */
private defaulted;
constructor(opts: MsgMeshOptions);
/**
* credential 回傳目前的鑑權憑證:apiKey 模式回 apiKey;getToken 模式回快取的短期 token,
* 將過期(含 skew)時自動透過 getToken 重取。refreshing 確保併發呼叫只觸發一次重取。
* Returns the current credential: the apiKey in apiKey mode; in getToken mode, the cached
* short-lived token, refetched via getToken once it is about to expire (including skew).
* `refreshing` guarantees concurrent callers trigger only one refetch.
*/
private credential;
/** invalidateToken 使快取 token 失效,強制下次重取(getToken 模式下遇 401 時用)。 */
/** Invalidates the cached token so the next call refetches (used on a 401 in getToken mode). */
private invalidateToken;
private authHeader;
/**
* f 發出已鑑權請求。getToken 模式下若收到 401(快取 token 過期/被拒:時鐘偏移、伺服器側 TTL 較短、
* 簽章輪替),失效快取並以新 token 重試一次(401 表示請求未被處理,重試安全,含 POST/DELETE)。
* 連線層錯誤(非 HTTP)由 attempt 包裝設定提示後拋出。
* Issues an authenticated request. In getToken mode a 401 (cached token expired or rejected —
* clock skew, a shorter server-side TTL, signing-key rotation) invalidates the cache and
* retries once with a fresh token. A 401 means the request was never processed, so retrying is
* safe even for POST/DELETE. Connection-level errors (not HTTP) are thrown by `attempt` with a
* configuration hint attached.
*/
private f;
/** attempt 包一層 fetch:用預設 URL 連線失敗時,在錯誤訊息附設定提示。 */
/** Wraps fetch: when a connection to a *defaulted* URL fails, append a configuration hint to the error. */
private attempt;
createTopic(name: string, partitions?: number): Promise<Topic>;
listTopics(): Promise<Topic[]>;
/**
* Deletes a topic. **This destroys data irreversibly**: the topic's messages, its dead-letter
* queue (`.dlq`), schema versions, transform function, and webhook bindings are all removed —
* recreating the same name only gets you a brand-new empty topic.
* Idempotent: deleting a topic that does not exist is not an error. If cleanup is incomplete
* it throws rather than pretending to succeed, so you can simply retry.
*/
deleteTopic(name: string): Promise<void>;

@@ -346,7 +361,10 @@ publish(topic: string, body: unknown, opts?: {

/**
* subscribe 持續輪詢,回傳停止函式。
* opts.onError(可選):每次輪詢出錯時回報。
* 終態 vs 可恢復:**只有 401(金鑰失效/不存在=終態)才永久停止重試**——不再無聲無限地拿失效金鑰重打;
* 403(可能為治理停權 suspended,可自助充值解封→可恢復)與其他暫時性錯誤一律回報後退避續試(維持自癒)。
* 永久停止(401)時若呼叫端未提供 onError,會 console.warn 一行(說明已停、原因、建議提供 onError),避免靜默死掉。
* Polls continuously; returns a stop function.
* `opts.onError` (optional) is called on every failed poll.
* Terminal vs recoverable: **only a 401 (key invalid or gone = terminal) stops retrying for
* good** — no more silently hammering forever with a dead key. A 403 (possibly a governance
* suspension, which the tenant can lift by topping up = recoverable) and any other transient
* error are reported and then retried with backoff, so the subscription still self-heals.
* When it stops permanently and the caller supplied no onError, it emits a single
* console.warn (what stopped, why, and to pass onError) rather than dying silently.
*/

@@ -360,10 +378,12 @@ subscribe(topic: string, opts: {

getSnippet(): Promise<string>;
/** getDocs 取得由租戶 topics+schema 生成的 Markdown 使用文件。 */
/** Fetches Markdown usage docs generated from this tenant's topics and schemas. */
getDocs(): Promise<string>;
listKeys(): Promise<APIKey[]>;
/**
* 簽發 API key。省略 capabilities → 角色鍵(scope=admin|producer|consumer);
* 提供 capabilities → 細粒度能力鍵(scope 須為 producer/consumer,非 admin),
* 形狀 [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }]。
* rooms 選用(房間隔離):省略/空 = 所有房間;非空 = 僅限這些房間(發佈的 ?key / 訂閱的 ?room),平台強制。
* Issues an API key. Omit `capabilities` for a role key (scope = admin|producer|consumer);
* supply `capabilities` for a fine-grained capability key (scope must be producer/consumer,
* not admin), shaped as
* [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }].
* `rooms` is optional (room isolation): omitted or empty = all rooms; non-empty = only these
* rooms — enforced by the platform on publish (`?key`) and subscribe (`?room`).
*/

@@ -385,21 +405,23 @@ createKey(scope: string, opts?: {

getAudit(limit?: number): Promise<AuditEntry[]>;
/** getBilling 查詢預付餘額 + 計費狀態 + 續航(加密貨幣 PAYG 帳本)。 */
/** Fetches prepaid balance, billing status, and runway (the crypto PAYG ledger). */
getBilling(): Promise<Billing>;
/** getDepositAddresses 取各鏈 watch-only 收款地址(QR / 複製用)。 */
/** Fetches the watch-only receiving address for each chain (for QR display / copy-paste). */
getDepositAddresses(): Promise<DepositAddress[]>;
/** getDeposits 列出鏈上 USDT 入帳(分頁:cursor 取自前一頁 next_cursor)。 */
/** Lists on-chain USDT deposits (paginated: `cursor` comes from the previous page's next_cursor). */
getDeposits(cursor?: string, limit?: number): Promise<Page<Deposit>>;
/** getLedger 列出帳本流水(分頁)。 */
/** Lists ledger entries (paginated). */
getLedger(cursor?: string, limit?: number): Promise<Page<LedgerEntry>>;
/** getUsageDebits 列出每日用量扣款(分頁)。 */
/** Lists daily usage debits (paginated). */
getUsageDebits(cursor?: string, limit?: number): Promise<Page<UsageDebit>>;
/** getDepositStatus 自助查某 tx_hash 的入帳狀態(限本租戶;未命中回 found=false)。 */
/** Self-service lookup of a tx_hash's credit status (this tenant only; a miss returns found=false). */
getDepositStatus(txHash: string): Promise<DepositStatus>;
getPlan(): Promise<PlanLimits>;
setPlan(plan: string): Promise<PlanLimits>;
/** getSettings 取得租戶層級設定(目前:strict_topics)。 */
/** Fetches tenant-level settings (currently just strict_topics). */
getSettings(): Promise<Settings>;
/**
* setStrictTopics 開/關資料面 topic 閘門。開啟後,發/收/SSE/WS 到「未由控制面建立」的 topic 一律 404
*(堵住憑空開 topic、繞過配額/分區/schema);關閉(預設)維持鬆散。
* Turns the data-plane topic gate on or off. Once on, publishing / consuming / SSE / WS
* against a topic that was never created through the control plane returns 404 — closing the
* door on conjuring topics out of thin air and bypassing quota, partitioning, and schema.
* Off (the default) stays permissive.
*/

@@ -410,7 +432,8 @@ setStrictTopics(enabled: boolean): Promise<Settings>;

deleteWebhook(id: string): Promise<void>;
/** reactivateWebhook 重新啟用被停權(如 URL 未過 SSRF 防護)的 webhook;查無此 webhook 會拋 NotFoundError。 */
/** Reactivates a suspended webhook (e.g. one whose URL failed SSRF protection); throws NotFoundError if it does not exist. */
reactivateWebhook(id: string): Promise<void>;
/**
* registerFunction 為 topic 註冊轉換函數。language 預設 javascript(goja JS 沙箱);
* 傳 "wasm" 則 code 須為 base64 編碼的 WASI 模組二進位(讀 stdin JSON、寫 stdout JSON)。
* Registers a transform function on a topic. `language` defaults to javascript (goja JS
* sandbox); pass "wasm" and `code` must be the base64-encoded WASI module binary (which
* reads JSON on stdin and writes JSON to stdout).
*/

@@ -423,3 +446,3 @@ registerFunction(topic: string, code: string, language?: string): Promise<TopicFunction>;

getLatestSchema(topic: string): Promise<SchemaVersion>;
/** deleteSchema 刪除 topic 的指定 schema 版本(不可刪 latest:409;無此版:404)。 */
/** Deletes one schema version from a topic (409 if it is the latest — that one cannot be deleted; 404 if the version does not exist). */
deleteSchema(topic: string, version: number): Promise<void>;

@@ -431,38 +454,52 @@ dlqPeek(topic: string, opts?: {

/**
* dlqReplay 將 DLQ 訊息重放回主 topic。回傳 replayed(本次重放筆數)與 has_more
* (DLQ 是否仍有可重放訊息;true 表可再次呼叫續放,用於分批清空大量 DLQ)。
* Replays DLQ messages back onto the main topic. Returns `replayed` (how many this call moved)
* and `has_more` (whether the DLQ still holds replayable messages; true means call again to
* continue — useful for draining a large DLQ in batches).
*/
dlqReplay(topic: string, max?: number): Promise<ReplayResult>;
/**
* getPresence 查詢 topic 目前線上連線數(realtime 服務)。
* 鑑權走 Authorization header(非 query key):此為一般 fetch,可設 header,避免把 API Key
* 放進 query→realtime 存取日誌。僅 stream()(EventSource 無法設 header)才用 query key。
* Fetches a topic's current online connection count (from the realtime service).
* Authenticates with an Authorization header, not a query key: this is an ordinary fetch so a
* header is possible, which keeps the API key out of the query string — and therefore out of
* realtime's access log. Only stream() uses a query key, because EventSource cannot set headers.
*/
getPresence(topic: string): Promise<Presence>;
/**
* stream 透過 SSE 即時接收訊息(僅瀏覽器:依賴 EventSource)。回傳停止函式。
* 連線鑑權走 query key(瀏覽器無法設 Authorization header)。
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a
* stop function. The connection authenticates with a query key, because a browser cannot set
* an Authorization header on an EventSource.
*
* onError 會收到兩類事件:
* - EventSource 連線錯誤(Event)。
* - 伺服器具名控制事件 msgmesh-close(MessageEvent,e.data 為原因字串)。
* `"authorization revoked"`=撤權(終態),SDK 主動停止重連,呼叫端應提示重新登入。
* `onError` receives two kinds of event:
* - An EventSource connection error (`Event`).
* - The server's named control event `msgmesh-close` (`MessageEvent`, reason in `e.data`).
* `"authorization revoked"` is terminal: the SDK stops reconnecting on its own, and the
* caller should prompt the user to sign in again.
*
* 重連:apiKey 模式交 EventSource 原生重連(撤權靠 msgmesh-close 收口)。getToken 模式由 SDK 接管——
* 任何連線錯誤都以「新 token」重連(失效快取),避免拿過期 token 死循環;並對「連續」失敗設上限
* (MAX_AUTH_RETRIES),達上限視為永久撤權而停止(一次成功連上即歸零),以免對已撤權者無限重連。
* **Reconnect.** In apiKey mode, EventSource's native reconnect handles it (revocation is closed
* out by `msgmesh-close`). In getToken mode the SDK takes over: every connection error
* reconnects with a *fresh* token (the cache is invalidated), so an expired token can never spin
* forever; and *consecutive* failures are capped (MAX_AUTH_RETRIES), after which it treats the
* credential as permanently revoked and stops. Any successful connect resets the counter.
*
* 續傳與去重(#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」
* 自去重。
* **Resume and dedupe (#16).** Every data message carries an `id:<partition>-<offset>` cursor.
* The SDK remembers the last id it saw and sends it back on reconnect (getToken mode manages its
* own reconnect and appends `&from=<id>`; apiKey mode relies on EventSource's native
* `Last-Event-ID` header). The server seeks back, backfills what was missed while disconnected,
* then rejoins live. Because delivery is **at-least-once** (the overlap window resends
* messages), the SDK always **dedupes per partition by id** — skipping any offset <= the highest
* already seen for that partition — before calling `onMessage`. This holds in both modes.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* you recorded before the last `stop()`); sent as `&from=` on the very first connect. This is
* what makes resume work across `stop()` → `stream()` again.
* - `opts.onResync` (optional): fires when the server sends `msgmesh-resync` — its replay window
* could not cover the gap, so completeness is not guaranteed. The SDK clears the resume cursor
* and dedupe state; the caller should **re-fetch a history snapshot itself** (the SDK does not
* manage history). Later live messages dedupe by id against "snapshot + live" on their own.
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定
* 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的
* room 偷聽同 topic 其他房間;真隔離需 token 帶 room scope,MVP 階段隔離靠你的後端 token-broker + 誠實 client。
* `opts.room` (optional, multi-room routing): receive only messages whose Kafka record key
* equals `room` (publish targets a room via publish's `key`). Omitting it receives everything on
* the topic. ⚠️ `room` is only a routing filter — **the server does not enforce isolation**. A
* malicious client can switch to someone else's `room` and eavesdrop on other rooms in the same
* topic. Real isolation requires a token carrying a room scope; at the MVP stage, isolation
* rests on your backend token-broker plus an honest client.
*/

@@ -475,31 +512,48 @@ stream(topic: string, onMessage: (data: string) => void, onError?: (e: Event | MessageEvent) => void, opts?: {

/**
* streamWs 透過 WebSocket 即時接收訊息,介面與 stream(SSE)一致、回傳停止函式。用全域 `WebSocket`
* (瀏覽器原生;Node ≥ 22 亦內建全域 WebSocket)——環境無全域 WebSocket 時立即拋錯(Node < 22 請改用
* subscribe() 長輪詢,或把 ws 套件掛到 globalThis.WebSocket)。連線鑑權走 query key(WebSocket 無法設 header)。
* Receives messages in realtime over WebSocket. Same interface as stream() (SSE) and likewise
* returns a stop function. Uses the global `WebSocket` (native in browsers; also built into
* Node >= 22) — it throws immediately when no global WebSocket exists (on Node < 22, use
* subscribe() long-polling instead, or attach the `ws` package to globalThis.WebSocket). The
* connection authenticates with a query key, because WebSocket cannot set headers.
*
* 與 stream 的差異(WebSocket vs EventSource):
* - **WebSocket 無原生自動重連**(EventSource 有),故重連一律由 SDK 接管:每次斷線退避 1s 重連,
* 成功連上即重置失敗計數;**兩模式皆對「連續」失敗(未曾連上)設 MAX_AUTH_RETRIES 上限**——達上限
* 停止,避免對已撤銷憑證/持續不可用端點無限重連。getToken 模式另在重連前換新 token。
* - **撤權**:連線中撤權為 CLOSE 1008(PolicyViolation)+ reason `"authorization revoked"`(SSE 用具名
* msgmesh-close 事件),SDK 見此終態立即停止。**握手期**撤權/失效是 HTTP 401 → CloseEvent 1006(非 1008),
* 無法只靠 reason 判別,由上述連續失敗上限收口。其他暫時性關閉(`"authorization check unavailable"`
* /網路中斷)退避重連。`stop()` 主動關閉不觸發 onError、並清除待觸發的重連 timer。
* **How it differs from stream() (WebSocket vs EventSource):**
* - **WebSocket has no native auto-reconnect** (EventSource does), so the SDK owns reconnect
* entirely: back off 1s after each drop, reset the failure counter on a successful connect,
* and — **in both auth modes** — cap *consecutive* failures (never having connected) at
* MAX_AUTH_RETRIES, then stop. That prevents reconnecting forever to a revoked credential or
* a persistently unavailable endpoint. In getToken mode it also rotates the token first.
* - **Revocation.** Mid-connection revocation arrives as CLOSE 1008 (PolicyViolation) with
* reason `"authorization revoked"` (SSE uses the named msgmesh-close event instead); the SDK
* treats it as terminal and stops at once. **During the handshake**, revocation/invalidity is
* an HTTP 401 → CloseEvent **1006**, not 1008, so the reason alone cannot identify it — that
* case is closed out by the consecutive-failure cap above. Other transient closes
* (`"authorization check unavailable"`, network drops) back off and reconnect. `stop()` closes
* deliberately: it does not fire onError, and it clears any pending reconnect timer.
*
* onMessage 收到每則事件的文字內容(訊息 value 字串);onError 收到 Event(連線錯誤)或 CloseEvent
* (關閉,可讀 e.code / e.reason)。
* `onMessage` receives each event's text content (the message value string); `onError` receives
* an `Event` (connection error) or a `CloseEvent` (closed — read `e.code` / `e.reason`).
*
* 續傳與去重(#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 原樣投遞、不去重(不誤丟)。
* **Resume and dedupe (#47 stage-2, mirroring stream()).** streamWs **always** connects with
* `?resume=1`, entering envelope mode (strict resume; SSE already defaults to it). The server
* wraps each data message as `{"id":"<partition>-<offset>","data":"<value>"}`; the SDK unwraps it
* transparently and hands the raw value to onMessage (a missing `data` = empty payload = empty
* string) — **the signature is unchanged**. The SDK remembers the last id seen and appends
* `&from=<id>` when reconnecting; the server seeks back, backfills what was missed, then rejoins
* live. Because delivery is **at-least-once** (the overlap window resends messages), the SDK
* always **dedupes per partition by id** (skipping any offset <= the highest seen for that
* partition) before calling onMessage.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* recorded before the last `stop()`); sent as `&from=` on the very first connect.
* - `opts.onResync` (optional): fires when the server sends the in-band control envelope
* `{"event":"msgmesh-resync"}` — its replay window could not cover the gap, so completeness is
* not guaranteed. The SDK clears the resume cursor and dedupe state; the caller should
* **re-fetch a history snapshot itself** (the SDK does not manage history). Later live
* messages dedupe by id against "snapshot + live". Revocation still travels as CLOSE 1008
* (above), never through an envelope.
* - Backward compatibility / safety valve: a **non-envelope** message (JSON without id/event, or
* not JSON at all) fails open — delivered as-is, not deduped, so nothing is wrongly dropped.
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。
* ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。
* `opts.room` (optional, multi-room routing): same as stream() — receive only messages whose
* Kafka record key equals `room`; omit it to receive everything.
* ⚠️ Routing filter only; the server does not enforce isolation (see stream()'s notes).
*/

@@ -506,0 +560,0 @@ streamWs(topic: string, onMessage: (data: string) => void, onError?: (e: Event | CloseEvent) => void, opts?: {

@@ -7,4 +7,5 @@ // src/errors.ts

/**
* 後端為此回應產生的追蹤 ID(取自 X-Request-Id header,或回應 body 的 request_id)。
* 已併入 error message 末尾(` [request_id: xxx]`),回報問題時附上可加速定位。
* The trace ID the server generated for this response (from the `X-Request-Id`
* header, or `request_id` in the body). It is appended to the error message
* (` [request_id: xxx]`) — include it when reporting a problem to speed up triage.
*/

@@ -124,3 +125,3 @@ requestId;

getToken;
/** 短期 token 快取(getToken 模式):值 + 到期時間(epoch ms);refreshing 防併發重取(thundering herd)。 */
/** Short-lived token cache (getToken mode): the value plus its expiry (epoch ms). `refreshing` collapses concurrent refetches into one (thundering herd). */
cachedToken;

@@ -133,3 +134,3 @@ tokenExpiry = 0;

fetchImpl;
/** 未明確指定的服務 URL(base → 選項名),連線失敗時提示使用者設定。 */
/** Service URLs left unset (base → option name), used to tell the caller which one to configure when a connection fails. */
defaulted;

@@ -140,3 +141,3 @@ constructor(opts) {

if (!this.apiKey && !this.getToken) {
throw new Error("msgmesh: \u9700\u63D0\u4F9B apiKey(\u4F3A\u670D\u5668\u7AEF)\u6216 getToken(\u700F\u89BD\u5668/\u4E0D\u53EF\u4FE1\u7AEF)\u5176\u4E00");
throw new Error("msgmesh: provide either apiKey (server-side) or getToken (browser / untrusted client)");
}

@@ -153,4 +154,5 @@ this.cp = (opts.controlPlaneUrl ?? DEFAULT_CP).replace(/\/$/, "");

/**
* credential 回傳目前的鑑權憑證:apiKey 模式回 apiKey;getToken 模式回快取的短期 token,
* 將過期(含 skew)時自動透過 getToken 重取。refreshing 確保併發呼叫只觸發一次重取。
* Returns the current credential: the apiKey in apiKey mode; in getToken mode, the cached
* short-lived token, refetched via getToken once it is about to expire (including skew).
* `refreshing` guarantees concurrent callers trigger only one refetch.
*/

@@ -176,3 +178,3 @@ async credential() {

}
/** invalidateToken 使快取 token 失效,強制下次重取(getToken 模式下遇 401 時用)。 */
/** Invalidates the cached token so the next call refetches (used on a 401 in getToken mode). */
invalidateToken() {

@@ -186,5 +188,7 @@ this.cachedToken = void 0;

/**
* f 發出已鑑權請求。getToken 模式下若收到 401(快取 token 過期/被拒:時鐘偏移、伺服器側 TTL 較短、
* 簽章輪替),失效快取並以新 token 重試一次(401 表示請求未被處理,重試安全,含 POST/DELETE)。
* 連線層錯誤(非 HTTP)由 attempt 包裝設定提示後拋出。
* Issues an authenticated request. In getToken mode a 401 (cached token expired or rejected —
* clock skew, a shorter server-side TTL, signing-key rotation) invalidates the cache and
* retries once with a fresh token. A 401 means the request was never processed, so retrying is
* safe even for POST/DELETE. Connection-level errors (not HTTP) are thrown by `attempt` with a
* configuration hint attached.
*/

@@ -200,3 +204,3 @@ async f(url, init) {

}
/** attempt 包一層 fetch:用預設 URL 連線失敗時,在錯誤訊息附設定提示。 */
/** Wraps fetch: when a connection to a *defaulted* URL fails, append a configuration hint to the error. */
async attempt(url, init) {

@@ -216,3 +220,3 @@ try {

throw new Error(
`msgmesh: \u5617\u8A66\u9023\u7DDA ${safeUrl} \u5931\u6557(${err instanceof Error ? err.message : String(err)}),\u8ACB\u6307\u5B9A ${hit[1]}(\u76EE\u524D\u4F7F\u7528\u672C\u6A5F\u9810\u8A2D\u503C)`,
`msgmesh: failed to connect to ${safeUrl} (${err instanceof Error ? err.message : String(err)}); set ${hit[1]} \u2014 it is currently falling back to the local-dev default`,
{ cause: err }

@@ -236,2 +240,9 @@ );

}
/**
* Deletes a topic. **This destroys data irreversibly**: the topic's messages, its dead-letter
* queue (`.dlq`), schema versions, transform function, and webhook bindings are all removed —
* recreating the same name only gets you a brand-new empty topic.
* Idempotent: deleting a topic that does not exist is not an error. If cleanup is incomplete
* it throws rather than pretending to succeed, so you can simply retry.
*/
async deleteTopic(name) {

@@ -265,7 +276,10 @@ const res = await this.f(`${this.cp}/v1/topics/${encodeURIComponent(name)}`, {

/**
* subscribe 持續輪詢,回傳停止函式。
* opts.onError(可選):每次輪詢出錯時回報。
* 終態 vs 可恢復:**只有 401(金鑰失效/不存在=終態)才永久停止重試**——不再無聲無限地拿失效金鑰重打;
* 403(可能為治理停權 suspended,可自助充值解封→可恢復)與其他暫時性錯誤一律回報後退避續試(維持自癒)。
* 永久停止(401)時若呼叫端未提供 onError,會 console.warn 一行(說明已停、原因、建議提供 onError),避免靜默死掉。
* Polls continuously; returns a stop function.
* `opts.onError` (optional) is called on every failed poll.
* Terminal vs recoverable: **only a 401 (key invalid or gone = terminal) stops retrying for
* good** — no more silently hammering forever with a dead key. A 403 (possibly a governance
* suspension, which the tenant can lift by topping up = recoverable) and any other transient
* error are reported and then retried with backoff, so the subscription still self-heals.
* When it stops permanently and the caller supplied no onError, it emits a single
* console.warn (what stopped, why, and to pass onError) rather than dying silently.
*/

@@ -290,3 +304,3 @@ subscribe(topic, opts, handler) {

console.warn(
"msgmesh: subscribe \u5DF2\u505C\u6B62 \u2014 \u9023\u7E8C\u591A\u6B21 HTTP 401(\u6191\u8B49\u53EF\u80FD\u5DF2\u6C38\u4E45\u64A4\u92B7)\u3002\u8ACB\u65BC\u91CD\u65B0\u53D6\u5F97\u6388\u6B0A\u5F8C\u91CD\u65B0\u8A02\u95B1(\u63D0\u4F9B opts.onError \u53EF\u63A5\u6536\u6B64\u4E8B\u4EF6)\u3002",
"msgmesh: subscribe stopped \u2014 repeated HTTP 401 (the credential may have been permanently revoked). Re-subscribe once you have re-authorized; pass opts.onError to receive this event instead of this warning.",
err

@@ -305,3 +319,3 @@ );

console.warn(
"msgmesh: subscribe \u5DF2\u6C38\u4E45\u505C\u6B62 \u2014 API key \u5931\u6548\u6216\u4E0D\u5B58\u5728(HTTP 401)\u3002\u8ACB\u63D0\u4F9B opts.onError \u63A5\u6536\u6B64\u4E8B\u4EF6,\u4E26\u65BC\u91CD\u65B0\u53D6\u5F97\u6191\u8B49\u5F8C\u91CD\u65B0\u8A02\u95B1\u3002",
"msgmesh: subscribe stopped permanently \u2014 the API key is invalid or does not exist (HTTP 401). Pass opts.onError to receive this event, and re-subscribe once you have a new credential.",
err

@@ -331,3 +345,3 @@ );

}
/** getDocs 取得由租戶 topics+schema 生成的 Markdown 使用文件。 */
/** Fetches Markdown usage docs generated from this tenant's topics and schemas. */
async getDocs() {

@@ -344,6 +358,8 @@ const res = await this.f(`${this.cp}/v1/docs`, { headers: await this.authHeader() });

/**
* 簽發 API key。省略 capabilities → 角色鍵(scope=admin|producer|consumer);
* 提供 capabilities → 細粒度能力鍵(scope 須為 producer/consumer,非 admin),
* 形狀 [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }]。
* rooms 選用(房間隔離):省略/空 = 所有房間;非空 = 僅限這些房間(發佈的 ?key / 訂閱的 ?room),平台強制。
* Issues an API key. Omit `capabilities` for a role key (scope = admin|producer|consumer);
* supply `capabilities` for a fine-grained capability key (scope must be producer/consumer,
* not admin), shaped as
* [{ ops:["publish"|"subscribe"], topics:["orders","support*","*"], rooms?:["room.42"] }].
* `rooms` is optional (room isolation): omitted or empty = all rooms; non-empty = only these
* rooms — enforced by the platform on publish (`?key`) and subscribe (`?room`).
*/

@@ -370,3 +386,3 @@ async createKey(scope, opts) {

}
/** getBilling 查詢預付餘額 + 計費狀態 + 續航(加密貨幣 PAYG 帳本)。 */
/** Fetches prepaid balance, billing status, and runway (the crypto PAYG ledger). */
async getBilling() {

@@ -376,3 +392,3 @@ const res = await this.f(`${this.cp}/v1/billing`, { headers: await this.authHeader() });

}
/** getDepositAddresses 取各鏈 watch-only 收款地址(QR / 複製用)。 */
/** Fetches the watch-only receiving address for each chain (for QR display / copy-paste). */
async getDepositAddresses() {

@@ -382,3 +398,3 @@ const res = await this.f(`${this.cp}/v1/billing/deposit-addresses`, { headers: await this.authHeader() });

}
/** getDeposits 列出鏈上 USDT 入帳(分頁:cursor 取自前一頁 next_cursor)。 */
/** Lists on-chain USDT deposits (paginated: `cursor` comes from the previous page's next_cursor). */
async getDeposits(cursor, limit) {

@@ -388,3 +404,3 @@ const res = await this.f(`${this.cp}/v1/billing/deposits${pageQuery(cursor, limit)}`, { headers: await this.authHeader() });

}
/** getLedger 列出帳本流水(分頁)。 */
/** Lists ledger entries (paginated). */
async getLedger(cursor, limit) {

@@ -394,3 +410,3 @@ const res = await this.f(`${this.cp}/v1/billing/ledger${pageQuery(cursor, limit)}`, { headers: await this.authHeader() });

}
/** getUsageDebits 列出每日用量扣款(分頁)。 */
/** Lists daily usage debits (paginated). */
async getUsageDebits(cursor, limit) {

@@ -400,3 +416,3 @@ const res = await this.f(`${this.cp}/v1/billing/usage-debits${pageQuery(cursor, limit)}`, { headers: await this.authHeader() });

}
/** getDepositStatus 自助查某 tx_hash 的入帳狀態(限本租戶;未命中回 found=false)。 */
/** Self-service lookup of a tx_hash's credit status (this tenant only; a miss returns found=false). */
async getDepositStatus(txHash) {

@@ -423,3 +439,3 @@ const res = await this.f(

// --- Settings ---
/** getSettings 取得租戶層級設定(目前:strict_topics)。 */
/** Fetches tenant-level settings (currently just strict_topics). */
async getSettings() {

@@ -430,4 +446,6 @@ const res = await this.f(`${this.cp}/v1/settings`, { headers: await this.authHeader() });

/**
* setStrictTopics 開/關資料面 topic 閘門。開啟後,發/收/SSE/WS 到「未由控制面建立」的 topic 一律 404
*(堵住憑空開 topic、繞過配額/分區/schema);關閉(預設)維持鬆散。
* Turns the data-plane topic gate on or off. Once on, publishing / consuming / SSE / WS
* against a topic that was never created through the control plane returns 404 — closing the
* door on conjuring topics out of thin air and bypassing quota, partitioning, and schema.
* Off (the default) stays permissive.
*/

@@ -462,3 +480,3 @@ async setStrictTopics(enabled) {

}
/** reactivateWebhook 重新啟用被停權(如 URL 未過 SSRF 防護)的 webhook;查無此 webhook 會拋 NotFoundError。 */
/** Reactivates a suspended webhook (e.g. one whose URL failed SSRF protection); throws NotFoundError if it does not exist. */
async reactivateWebhook(id) {

@@ -471,6 +489,7 @@ const res = await this.f(`${this.cp}/v1/webhooks/${encodeURIComponent(id)}/reactivate`, {

}
// --- Functions(綁定 topic 的訊息轉換函數)---
// --- Functions (message-transform functions bound to a topic) ---
/**
* registerFunction 為 topic 註冊轉換函數。language 預設 javascript(goja JS 沙箱);
* 傳 "wasm" 則 code 須為 base64 編碼的 WASI 模組二進位(讀 stdin JSON、寫 stdout JSON)。
* Registers a transform function on a topic. `language` defaults to javascript (goja JS
* sandbox); pass "wasm" and `code` must be the base64-encoded WASI module binary (which
* reads JSON on stdin and writes JSON to stdout).
*/

@@ -515,3 +534,3 @@ async registerFunction(topic, code, language) {

}
/** deleteSchema 刪除 topic 的指定 schema 版本(不可刪 latest:409;無此版:404)。 */
/** Deletes one schema version from a topic (409 if it is the latest — that one cannot be deleted; 404 if the version does not exist). */
async deleteSchema(topic, version) {

@@ -537,4 +556,5 @@ const res = await this.f(

/**
* dlqReplay 將 DLQ 訊息重放回主 topic。回傳 replayed(本次重放筆數)與 has_more
* (DLQ 是否仍有可重放訊息;true 表可再次呼叫續放,用於分批清空大量 DLQ)。
* Replays DLQ messages back onto the main topic. Returns `replayed` (how many this call moved)
* and `has_more` (whether the DLQ still holds replayable messages; true means call again to
* continue — useful for draining a large DLQ in batches).
*/

@@ -550,5 +570,6 @@ async dlqReplay(topic, max) {

/**
* getPresence 查詢 topic 目前線上連線數(realtime 服務)。
* 鑑權走 Authorization header(非 query key):此為一般 fetch,可設 header,避免把 API Key
* 放進 query→realtime 存取日誌。僅 stream()(EventSource 無法設 header)才用 query key。
* Fetches a topic's current online connection count (from the realtime service).
* Authenticates with an Authorization header, not a query key: this is an ordinary fetch so a
* header is possible, which keeps the API key out of the query string — and therefore out of
* realtime's access log. Only stream() uses a query key, because EventSource cannot set headers.
*/

@@ -563,27 +584,39 @@ async getPresence(topic) {

/**
* stream 透過 SSE 即時接收訊息(僅瀏覽器:依賴 EventSource)。回傳停止函式。
* 連線鑑權走 query key(瀏覽器無法設 Authorization header)。
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a
* stop function. The connection authenticates with a query key, because a browser cannot set
* an Authorization header on an EventSource.
*
* onError 會收到兩類事件:
* - EventSource 連線錯誤(Event)。
* - 伺服器具名控制事件 msgmesh-close(MessageEvent,e.data 為原因字串)。
* `"authorization revoked"`=撤權(終態),SDK 主動停止重連,呼叫端應提示重新登入。
* `onError` receives two kinds of event:
* - An EventSource connection error (`Event`).
* - The server's named control event `msgmesh-close` (`MessageEvent`, reason in `e.data`).
* `"authorization revoked"` is terminal: the SDK stops reconnecting on its own, and the
* caller should prompt the user to sign in again.
*
* 重連:apiKey 模式交 EventSource 原生重連(撤權靠 msgmesh-close 收口)。getToken 模式由 SDK 接管——
* 任何連線錯誤都以「新 token」重連(失效快取),避免拿過期 token 死循環;並對「連續」失敗設上限
* (MAX_AUTH_RETRIES),達上限視為永久撤權而停止(一次成功連上即歸零),以免對已撤權者無限重連。
* **Reconnect.** In apiKey mode, EventSource's native reconnect handles it (revocation is closed
* out by `msgmesh-close`). In getToken mode the SDK takes over: every connection error
* reconnects with a *fresh* token (the cache is invalidated), so an expired token can never spin
* forever; and *consecutive* failures are capped (MAX_AUTH_RETRIES), after which it treats the
* credential as permanently revoked and stops. Any successful connect resets the counter.
*
* 續傳與去重(#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」
* 自去重。
* **Resume and dedupe (#16).** Every data message carries an `id:<partition>-<offset>` cursor.
* The SDK remembers the last id it saw and sends it back on reconnect (getToken mode manages its
* own reconnect and appends `&from=<id>`; apiKey mode relies on EventSource's native
* `Last-Event-ID` header). The server seeks back, backfills what was missed while disconnected,
* then rejoins live. Because delivery is **at-least-once** (the overlap window resends
* messages), the SDK always **dedupes per partition by id** — skipping any offset <= the highest
* already seen for that partition — before calling `onMessage`. This holds in both modes.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* you recorded before the last `stop()`); sent as `&from=` on the very first connect. This is
* what makes resume work across `stop()` → `stream()` again.
* - `opts.onResync` (optional): fires when the server sends `msgmesh-resync` — its replay window
* could not cover the gap, so completeness is not guaranteed. The SDK clears the resume cursor
* and dedupe state; the caller should **re-fetch a history snapshot itself** (the SDK does not
* manage history). Later live messages dedupe by id against "snapshot + live" on their own.
*
* opts.room(可選,多房間路由):只接收 Kafka record key 等於 room 的訊息(發佈時用 publish 的 key 指定
* 房間)。省略=收該 topic 全部訊息。⚠️ room 只是路由過濾、**無伺服器強制隔離**——惡意 client 可改成別人的
* room 偷聽同 topic 其他房間;真隔離需 token 帶 room scope,MVP 階段隔離靠你的後端 token-broker + 誠實 client。
* `opts.room` (optional, multi-room routing): receive only messages whose Kafka record key
* equals `room` (publish targets a room via publish's `key`). Omitting it receives everything on
* the topic. ⚠️ `room` is only a routing filter — **the server does not enforce isolation**. A
* malicious client can switch to someone else's `room` and eavesdrop on other rooms in the same
* topic. Real isolation requires a token carrying a room scope; at the MVP stage, isolation
* rests on your backend token-broker plus an honest client.
*/

@@ -606,3 +639,3 @@ stream(topic, onMessage, onError, opts) {

console.warn(
"msgmesh: stream \u5DF2\u505C\u6B62 \u2014 \u9023\u7E8C\u591A\u6B21\u9023\u7DDA\u5931\u6557(\u6191\u8B49\u53EF\u80FD\u5DF2\u6C38\u4E45\u64A4\u92B7)\u3002\u8ACB\u65BC\u91CD\u65B0\u53D6\u5F97\u6388\u6B0A\u5F8C\u91CD\u65B0\u547C\u53EB stream()\u3002"
"msgmesh: stream stopped \u2014 repeated connection failures (the credential may have been permanently revoked). Call stream() again once you have re-authorized."
);

@@ -662,31 +695,48 @@ return;

/**
* streamWs 透過 WebSocket 即時接收訊息,介面與 stream(SSE)一致、回傳停止函式。用全域 `WebSocket`
* (瀏覽器原生;Node ≥ 22 亦內建全域 WebSocket)——環境無全域 WebSocket 時立即拋錯(Node < 22 請改用
* subscribe() 長輪詢,或把 ws 套件掛到 globalThis.WebSocket)。連線鑑權走 query key(WebSocket 無法設 header)。
* Receives messages in realtime over WebSocket. Same interface as stream() (SSE) and likewise
* returns a stop function. Uses the global `WebSocket` (native in browsers; also built into
* Node >= 22) — it throws immediately when no global WebSocket exists (on Node < 22, use
* subscribe() long-polling instead, or attach the `ws` package to globalThis.WebSocket). The
* connection authenticates with a query key, because WebSocket cannot set headers.
*
* 與 stream 的差異(WebSocket vs EventSource):
* - **WebSocket 無原生自動重連**(EventSource 有),故重連一律由 SDK 接管:每次斷線退避 1s 重連,
* 成功連上即重置失敗計數;**兩模式皆對「連續」失敗(未曾連上)設 MAX_AUTH_RETRIES 上限**——達上限
* 停止,避免對已撤銷憑證/持續不可用端點無限重連。getToken 模式另在重連前換新 token。
* - **撤權**:連線中撤權為 CLOSE 1008(PolicyViolation)+ reason `"authorization revoked"`(SSE 用具名
* msgmesh-close 事件),SDK 見此終態立即停止。**握手期**撤權/失效是 HTTP 401 → CloseEvent 1006(非 1008),
* 無法只靠 reason 判別,由上述連續失敗上限收口。其他暫時性關閉(`"authorization check unavailable"`
* /網路中斷)退避重連。`stop()` 主動關閉不觸發 onError、並清除待觸發的重連 timer。
* **How it differs from stream() (WebSocket vs EventSource):**
* - **WebSocket has no native auto-reconnect** (EventSource does), so the SDK owns reconnect
* entirely: back off 1s after each drop, reset the failure counter on a successful connect,
* and — **in both auth modes** — cap *consecutive* failures (never having connected) at
* MAX_AUTH_RETRIES, then stop. That prevents reconnecting forever to a revoked credential or
* a persistently unavailable endpoint. In getToken mode it also rotates the token first.
* - **Revocation.** Mid-connection revocation arrives as CLOSE 1008 (PolicyViolation) with
* reason `"authorization revoked"` (SSE uses the named msgmesh-close event instead); the SDK
* treats it as terminal and stops at once. **During the handshake**, revocation/invalidity is
* an HTTP 401 → CloseEvent **1006**, not 1008, so the reason alone cannot identify it — that
* case is closed out by the consecutive-failure cap above. Other transient closes
* (`"authorization check unavailable"`, network drops) back off and reconnect. `stop()` closes
* deliberately: it does not fire onError, and it clears any pending reconnect timer.
*
* onMessage 收到每則事件的文字內容(訊息 value 字串);onError 收到 Event(連線錯誤)或 CloseEvent
* (關閉,可讀 e.code / e.reason)。
* `onMessage` receives each event's text content (the message value string); `onError` receives
* an `Event` (connection error) or a `CloseEvent` (closed — read `e.code` / `e.reason`).
*
* 續傳與去重(#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 原樣投遞、不去重(不誤丟)。
* **Resume and dedupe (#47 stage-2, mirroring stream()).** streamWs **always** connects with
* `?resume=1`, entering envelope mode (strict resume; SSE already defaults to it). The server
* wraps each data message as `{"id":"<partition>-<offset>","data":"<value>"}`; the SDK unwraps it
* transparently and hands the raw value to onMessage (a missing `data` = empty payload = empty
* string) — **the signature is unchanged**. The SDK remembers the last id seen and appends
* `&from=<id>` when reconnecting; the server seeks back, backfills what was missed, then rejoins
* live. Because delivery is **at-least-once** (the overlap window resends messages), the SDK
* always **dedupes per partition by id** (skipping any offset <= the highest seen for that
* partition) before calling onMessage.
* - `opts.from` (optional): the cursor to resume from, `<partition>-<offset>` (e.g. the position
* recorded before the last `stop()`); sent as `&from=` on the very first connect.
* - `opts.onResync` (optional): fires when the server sends the in-band control envelope
* `{"event":"msgmesh-resync"}` — its replay window could not cover the gap, so completeness is
* not guaranteed. The SDK clears the resume cursor and dedupe state; the caller should
* **re-fetch a history snapshot itself** (the SDK does not manage history). Later live
* messages dedupe by id against "snapshot + live". Revocation still travels as CLOSE 1008
* (above), never through an envelope.
* - Backward compatibility / safety valve: a **non-envelope** message (JSON without id/event, or
* not JSON at all) fails open — delivered as-is, not deduped, so nothing is wrongly dropped.
*
* opts.room(可選,多房間路由):同 stream() ——只收 Kafka record key==room 的訊息;省略=收全部。
* ⚠️ 只做路由過濾、無伺服器強制隔離(見 stream() 說明)。
* `opts.room` (optional, multi-room routing): same as stream() — receive only messages whose
* Kafka record key equals `room`; omit it to receive everything.
* ⚠️ Routing filter only; the server does not enforce isolation (see stream()'s notes).
*/

@@ -697,3 +747,3 @@ streamWs(topic, onMessage, onError, opts) {

throw new Error(
"msgmesh: streamWs \u9700\u8981\u5168\u57DF WebSocket(\u700F\u89BD\u5668\u6216 Node \u2265 22)\u3002Node < 22 \u8ACB\u6539\u7528 subscribe()(\u9577\u8F2A\u8A62),\u6216\u5C07 ws \u5957\u4EF6\u639B\u5230 globalThis.WebSocket\u3002"
"msgmesh: streamWs requires a global WebSocket (a browser, or Node >= 22). On Node < 22 use subscribe() (long-polling) instead, or attach the `ws` package to globalThis.WebSocket."
);

@@ -718,3 +768,3 @@ }

console.warn(
"msgmesh: streamWs \u5DF2\u505C\u6B62 \u2014 \u9023\u7E8C\u591A\u6B21\u9023\u7DDA\u5931\u6557(\u6191\u8B49\u5DF2\u64A4\u92B7\u6216\u7AEF\u9EDE\u6301\u7E8C\u4E0D\u53EF\u7528)\u3002\u8ACB\u65BC\u6062\u5FA9\u5F8C\u91CD\u65B0\u547C\u53EB streamWs()\u3002"
"msgmesh: streamWs stopped \u2014 repeated connection failures (the credential was revoked, or the endpoint is persistently unavailable). Call streamWs() again once it recovers."
);

@@ -721,0 +771,0 @@ return;

{
"name": "@msgmesh/sdk",
"version": "0.1.7",
"description": "MsgMesh TypeScript SDK — 多租戶事件總線的收發 / 即時(SSE·WS)/ 治理 client(Node 與瀏覽器通用)。",
"version": "0.1.8",
"description": "MsgMesh TypeScript SDK — publish / consume / realtime (SSE / WebSocket) / governance client for the multi-tenant event bus, universal across Node and the browser.",
"license": "MIT",
"author": "LukeLogix",
"homepage": "https://msg.alderflux.com",
"homepage": "https://msgmesh.alderflux.com",
"repository": {

@@ -9,0 +9,0 @@ "type": "git",