@msgmesh/sdk
Advanced tools
+143
-93
@@ -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; |
+179
-125
| 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?: { |
+179
-125
| 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?: { |
+143
-93
@@ -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; |
+3
-3
| { | ||
| "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", |
142810
9.08%2197
7.33%