Sign In

@msgmesh/sdk

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@msgmesh/sdk - npm Package Compare versions

Comparing version
0.1.8
to
0.2.0
+93
-2
dist/index.cjs

@@ -135,2 +135,5 @@ "use strict";

}
function anchorParam(v) {
return v instanceof Date ? String(v.getTime()) : v;
}
function parseEventId(id) {

@@ -279,4 +282,20 @@ if (!id) return null;

}
/**
* Publishes one message. `opts.room` routes it to a room — the same room you subscribe to with
* `stream({ room })` and read back as `message.room` from `poll()` / `history()`. Messages in one
* room are ordered relative to each other; omit it and the message is spread across partitions
* and belongs to no room.
*
* (This option was called `key` before v0.2.0. One name, one meaning: `key` now only ever means
* a credential. Passing the old `opts.key` **throws** — the server still accepts the deprecated
* `?key=` during the migration window, so a silently dropped `opts.key` would publish to a
* random partition, return 200, and leave the room empty with no signal at all.)
*/
async publish(topic, body, opts = {}) {
const qs = opts.key ? `?key=${encodeURIComponent(opts.key)}` : "";
if ("key" in opts) {
throw new TypeError(
"publish opts.key was renamed to opts.room in @msgmesh/sdk v0.2.0 \u2014 pass { room } instead (silently ignoring it would publish to a random partition outside every room)"
);
}
const qs = opts.room ? `?room=${encodeURIComponent(opts.room)}` : "";
const res = await this.f(`${this.gw}/v1/topics/${encodeURIComponent(topic)}/messages${qs}`, {

@@ -384,3 +403,3 @@ method: "POST",

* `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`).
* rooms — enforced by the platform on publish (`?room`) and subscribe (`?room`).
*/

@@ -592,2 +611,74 @@ async createKey(scope, opts) {

/**
* Fetches the most recent messages of a topic (or one `room`) so a late joiner does not face a
* blank screen. Pair it with `stream()`: fetch history, render it, then resume the live stream
* from the cursor history handed you.
*
* ```ts
* const seen = new Set<string>();
* const page = await mm.history("chat", { room: "lobby", limit: 50 });
* for (const m of page.messages) { seen.add(m.id); render(m.value); }
*
* // Hand off to live. `resume_from` is guaranteed to have been replayable when it was issued.
* mm.stream("chat", (data) => render(data), undefined, {
* room: "lobby",
* from: page.resume_from || undefined,
* });
* ```
*
* **Dedupe by id.** Delivery is at-least-once and the resume window deliberately overlaps, so the
* same message can arrive from both history and the stream. Every `HistoryMessage.id` uses the
* exact same `<partition>-<offset>` format as the stream's event id, so one `Set<string>` covers
* both. (`stream()` already dedupes what *it* delivers; the overlap with history is yours to
* dedupe, because the SDK does not hold your messages.)
*
* **Two cursors, two meanings.**
* - `resume_from` → hand to `stream()`/`streamWs()` as `from`. The server guarantees it was
* inside the replayable window when the response was produced, so it does not cost you a
* resync. It is **not necessarily** the id of the last message in the page: the scan runs
* newest → oldest, so the cursor is advanced to the top of the scanned range on purpose
* (nothing matching lives there), which maximises how long you may take to connect.
* Check `resume_gap`: `true` means resuming really would skip messages.
* - `before` → hand back to `history()` to page further back. **Not** guaranteed replayable;
* passing it to `stream()` can cost you a resync round-trip. It enumerates every partition the
* scan covered, so a `while (cursor)` loop progresses, terminates, and never repeats a page.
*
* **The SDK deliberately does not persist anything** (no localStorage, no IndexedDB): where a
* cursor belongs — memory, localStorage, your own backend — is your app's decision, not the
* SDK's. Keep `resume_from` in whatever store fits, and pass it back as `opts.from` next time.
*
* **`complete: true` means the scan stopped because it satisfied your request** — it filled the
* page to `limit`, or it reached the bottom of what exists. Not "the page was filled to `limit`",
* and **not** "there is nothing older": a full page of 50 with thousands more behind it is also
* `complete: true`. **Use `before` to decide whether to keep paging, never `complete`** (`limit`
* is silently capped server-side, so `messages.length < limit` is not a reliable test either).
* `complete: false` means the scan was cut short by something other than your request, and
* `incomplete_reason` says by what: `budget` (the older messages are still in Kafka, this scan
* just did not reach them — page back with `before`; covers the bounded record scan, the per-page
* byte cap, and a stalled broker) or `retention` (the `since` bound you passed reaches further
* back than the platform still keeps; deleted for good — a plain "latest N" query never returns
* this). Under the hood this is a *bounded scan over Kafka*, not a separate history database, so
* how far back you can reach depends on your plan's retention.
*
* `opts.room` — only messages published to that room (the same room you passed to `publish`).
* Omit to get the whole topic. ⚠️ Same caveat as `stream()`: room is a routing filter, not
* server-enforced isolation.
* `opts.before` / `opts.since` — bounds. Each accepts an RFC3339 timestamp, epoch **milliseconds**,
* or (for `before`) the `before` token from a previous page. Epoch *seconds* are rejected with a
* 400 rather than silently answering about 1970 — pass a `Date` and the SDK converts it for you.
*/
async history(topic, opts = {}) {
const params = new URLSearchParams();
if (opts.room) params.set("room", opts.room);
if (opts.limit != null) params.set("limit", String(opts.limit));
if (opts.before != null) params.set("before", anchorParam(opts.before));
if (opts.since != null) params.set("since", anchorParam(opts.since));
const qs = params.toString();
const res = await this.f(
`${this.rt}/v1/topics/${encodeURIComponent(topic)}/history${qs ? `?${qs}` : ""}`,
{ headers: await this.authHeader() }
);
const page = await asJSON(res);
return { ...page, messages: page.messages ?? [] };
}
/**
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a

@@ -594,0 +685,0 @@ * stop function. The connection authenticates with a query key, because a browser cannot set

@@ -86,2 +86,85 @@ interface MsgMeshOptions {

}
/** One message returned by `history()`. */
interface HistoryMessage {
/**
* Cursor `<partition>-<offset>` — the **same** id format the live stream puts on every message,
* so one `Set<string>` dedupes across "history + stream" without any extra bookkeeping.
*/
id: string;
partition: number;
offset: number;
/** The room it was published to (`publish(topic, body, { room })`); absent when published without one. */
room?: string;
/** The message content, exactly as published. */
value: string;
/** Message timestamp (UTC, millisecond precision). */
ts: string;
}
/**
* Why the scan stopped short of satisfying your request. Present only when `complete` is false.
* - `budget` — the older messages are still in Kafka, this scan just did not reach them. Covers
* three server-side limits with the same fix: the bounded record scan, the per-page byte cap, and
* a broker that stalled part-way. Pass `before` to keep paging.
* - `retention` — the `since` bound you passed reaches further back than the topic still keeps;
* that stretch has been deleted and paging will not bring it back. Only ever appears when you
* asked for a specific older range — a plain "latest N" query never returns it.
*
* The difference that matters: `budget` is recoverable by paging, `retention` is not.
*/
type HistoryIncompleteReason = "budget" | "retention";
/**
* The result of `history()`.
*
* **Two cursors, two different meanings — do not mix them up:**
* - `resume_from` hands off to the live stream (`stream(..., { from })`). The server **guarantees**
* it was inside the replayable window at the moment the response was produced, so handing it
* straight to `stream()` does not cost you a resync.
* - `before` pages further back. It carries **no such guarantee** — never pass it to `stream()`.
*/
interface HistoryPage {
/** Oldest → newest, the same order the live stream delivers in. */
messages: HistoryMessage[];
/**
* True when the scan stopped because it **satisfied your request** — it filled the page to
* `limit`, or it reached the bottom of what exists. Fewer messages than `limit`, or none at all,
* is still `true`.
*
* ⚠️ It does **not** tell you whether older messages exist: a full page with thousands more
* behind it is `true` too. **Read `before` to decide whether to keep paging** (non-empty = more
* is reachable). `false` means the scan was cut short by something else — see `incomplete_reason`.
*/
complete: boolean;
incomplete_reason?: HistoryIncompleteReason;
/**
* Cursor to hand to `stream()` / `streamWs()` as `from`. Empty string = no cursor available;
* just start a plain live stream.
*
* The server guarantees it was inside the replayable window when the response was produced.
* (The one thing it cannot cover is the time it takes you to connect: if that partition takes
* more than a full replay window of new messages in between, you still get a resync.)
*
* ⚠️ It is **not necessarily** the `id` of the last message in `messages`. The scan runs
* newest → oldest, so everything between the newest message and the top of the scanned range is
* already known to contain nothing matching — the cursor is advanced there on purpose, to leave
* the largest possible margin for the gap between "fetched history" and "connected".
*/
resume_from: string;
/**
* True when resuming from `resume_from` would skip messages. Two sources: the cursor had to be
* pulled back into the replayable window **and** the skipped range was not part of this scan
* (you paged further back than the replay window, or retention deleted the middle); or this was
* a whole-topic (no `room`) query on a multi-partition topic, where a single cursor cannot cover
* every partition. When it is false, everything skipped was scanned and verified empty — the
* flag does not fire "just to be safe".
*/
resume_gap: boolean;
/**
* Token for the next (older) page — pass it back as `before`. Empty string = nothing older.
* It enumerates every partition the scan covered (including the ones already exhausted), so a
* `while (cursor) { … }` loop is guaranteed to make progress, terminate, and never repeat a page.
*/
before: string;
/** How many Kafka messages the server examined (not how many it returned). */
scanned: number;
}
interface AuditEntry {

@@ -231,3 +314,4 @@ tenant_id: string;

offset: number;
key?: string;
/** The room it was published to (`publish(topic, body, { room })`); absent when published without one. */
room?: string;
value: string;

@@ -331,4 +415,15 @@ }

deleteTopic(name: string): Promise<void>;
/**
* Publishes one message. `opts.room` routes it to a room — the same room you subscribe to with
* `stream({ room })` and read back as `message.room` from `poll()` / `history()`. Messages in one
* room are ordered relative to each other; omit it and the message is spread across partitions
* and belongs to no room.
*
* (This option was called `key` before v0.2.0. One name, one meaning: `key` now only ever means
* a credential. Passing the old `opts.key` **throws** — the server still accepts the deprecated
* `?key=` during the migration window, so a silently dropped `opts.key` would publish to a
* random partition, return 200, and leave the room empty with no signal at all.)
*/
publish(topic: string, body: unknown, opts?: {
key?: string;
room?: string;
}): Promise<PublishResult>;

@@ -365,3 +460,3 @@ poll(topic: string, opts?: {

* `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`).
* rooms — enforced by the platform on publish (`?room`) and subscribe (`?room`).
*/

@@ -442,2 +537,66 @@ createKey(scope: string, opts?: {

/**
* Fetches the most recent messages of a topic (or one `room`) so a late joiner does not face a
* blank screen. Pair it with `stream()`: fetch history, render it, then resume the live stream
* from the cursor history handed you.
*
* ```ts
* const seen = new Set<string>();
* const page = await mm.history("chat", { room: "lobby", limit: 50 });
* for (const m of page.messages) { seen.add(m.id); render(m.value); }
*
* // Hand off to live. `resume_from` is guaranteed to have been replayable when it was issued.
* mm.stream("chat", (data) => render(data), undefined, {
* room: "lobby",
* from: page.resume_from || undefined,
* });
* ```
*
* **Dedupe by id.** Delivery is at-least-once and the resume window deliberately overlaps, so the
* same message can arrive from both history and the stream. Every `HistoryMessage.id` uses the
* exact same `<partition>-<offset>` format as the stream's event id, so one `Set<string>` covers
* both. (`stream()` already dedupes what *it* delivers; the overlap with history is yours to
* dedupe, because the SDK does not hold your messages.)
*
* **Two cursors, two meanings.**
* - `resume_from` → hand to `stream()`/`streamWs()` as `from`. The server guarantees it was
* inside the replayable window when the response was produced, so it does not cost you a
* resync. It is **not necessarily** the id of the last message in the page: the scan runs
* newest → oldest, so the cursor is advanced to the top of the scanned range on purpose
* (nothing matching lives there), which maximises how long you may take to connect.
* Check `resume_gap`: `true` means resuming really would skip messages.
* - `before` → hand back to `history()` to page further back. **Not** guaranteed replayable;
* passing it to `stream()` can cost you a resync round-trip. It enumerates every partition the
* scan covered, so a `while (cursor)` loop progresses, terminates, and never repeats a page.
*
* **The SDK deliberately does not persist anything** (no localStorage, no IndexedDB): where a
* cursor belongs — memory, localStorage, your own backend — is your app's decision, not the
* SDK's. Keep `resume_from` in whatever store fits, and pass it back as `opts.from` next time.
*
* **`complete: true` means the scan stopped because it satisfied your request** — it filled the
* page to `limit`, or it reached the bottom of what exists. Not "the page was filled to `limit`",
* and **not** "there is nothing older": a full page of 50 with thousands more behind it is also
* `complete: true`. **Use `before` to decide whether to keep paging, never `complete`** (`limit`
* is silently capped server-side, so `messages.length < limit` is not a reliable test either).
* `complete: false` means the scan was cut short by something other than your request, and
* `incomplete_reason` says by what: `budget` (the older messages are still in Kafka, this scan
* just did not reach them — page back with `before`; covers the bounded record scan, the per-page
* byte cap, and a stalled broker) or `retention` (the `since` bound you passed reaches further
* back than the platform still keeps; deleted for good — a plain "latest N" query never returns
* this). Under the hood this is a *bounded scan over Kafka*, not a separate history database, so
* how far back you can reach depends on your plan's retention.
*
* `opts.room` — only messages published to that room (the same room you passed to `publish`).
* Omit to get the whole topic. ⚠️ Same caveat as `stream()`: room is a routing filter, not
* server-enforced isolation.
* `opts.before` / `opts.since` — bounds. Each accepts an RFC3339 timestamp, epoch **milliseconds**,
* or (for `before`) the `before` token from a previous page. Epoch *seconds* are rejected with a
* 400 rather than silently answering about 1970 — pass a `Date` and the SDK converts it for you.
*/
history(topic: string, opts?: {
room?: string;
limit?: number;
before?: string | Date;
since?: string | Date;
}): Promise<HistoryPage>;
/**
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a

@@ -541,2 +700,2 @@ * stop function. The connection authenticates with a query key, because a browser cannot set

export { type APIKey, type AdjustResult, type AuditEntry, AuthError, type Billing, type Deposit, type DepositAddress, type DepositStatus, type FinanceChainAmount, type FinanceOverview, type FinanceTenant, type LedgerEntry, type Message, MsgMesh, MsgMeshError, type MsgMeshOptions, NotFoundError, type Overview, type Page, type PlanLimits, type Presence, type PublishResult, RateLimitError, type ReplayResult, type SchemaVersion, type Settings, type Tenant, type TokenResponse, type Topic, type TopicFunction, type UsageDebit, type UsageResponse, type UsageRow, ValidationError, type Webhook, errorFromResponse, errorFromStatus };
export { type APIKey, type AdjustResult, type AuditEntry, AuthError, type Billing, type Deposit, type DepositAddress, type DepositStatus, type FinanceChainAmount, type FinanceOverview, type FinanceTenant, type HistoryIncompleteReason, type HistoryMessage, type HistoryPage, type LedgerEntry, type Message, MsgMesh, MsgMeshError, type MsgMeshOptions, NotFoundError, type Overview, type Page, type PlanLimits, type Presence, type PublishResult, RateLimitError, type ReplayResult, type SchemaVersion, type Settings, type Tenant, type TokenResponse, type Topic, type TopicFunction, type UsageDebit, type UsageResponse, type UsageRow, ValidationError, type Webhook, errorFromResponse, errorFromStatus };

@@ -86,2 +86,85 @@ interface MsgMeshOptions {

}
/** One message returned by `history()`. */
interface HistoryMessage {
/**
* Cursor `<partition>-<offset>` — the **same** id format the live stream puts on every message,
* so one `Set<string>` dedupes across "history + stream" without any extra bookkeeping.
*/
id: string;
partition: number;
offset: number;
/** The room it was published to (`publish(topic, body, { room })`); absent when published without one. */
room?: string;
/** The message content, exactly as published. */
value: string;
/** Message timestamp (UTC, millisecond precision). */
ts: string;
}
/**
* Why the scan stopped short of satisfying your request. Present only when `complete` is false.
* - `budget` — the older messages are still in Kafka, this scan just did not reach them. Covers
* three server-side limits with the same fix: the bounded record scan, the per-page byte cap, and
* a broker that stalled part-way. Pass `before` to keep paging.
* - `retention` — the `since` bound you passed reaches further back than the topic still keeps;
* that stretch has been deleted and paging will not bring it back. Only ever appears when you
* asked for a specific older range — a plain "latest N" query never returns it.
*
* The difference that matters: `budget` is recoverable by paging, `retention` is not.
*/
type HistoryIncompleteReason = "budget" | "retention";
/**
* The result of `history()`.
*
* **Two cursors, two different meanings — do not mix them up:**
* - `resume_from` hands off to the live stream (`stream(..., { from })`). The server **guarantees**
* it was inside the replayable window at the moment the response was produced, so handing it
* straight to `stream()` does not cost you a resync.
* - `before` pages further back. It carries **no such guarantee** — never pass it to `stream()`.
*/
interface HistoryPage {
/** Oldest → newest, the same order the live stream delivers in. */
messages: HistoryMessage[];
/**
* True when the scan stopped because it **satisfied your request** — it filled the page to
* `limit`, or it reached the bottom of what exists. Fewer messages than `limit`, or none at all,
* is still `true`.
*
* ⚠️ It does **not** tell you whether older messages exist: a full page with thousands more
* behind it is `true` too. **Read `before` to decide whether to keep paging** (non-empty = more
* is reachable). `false` means the scan was cut short by something else — see `incomplete_reason`.
*/
complete: boolean;
incomplete_reason?: HistoryIncompleteReason;
/**
* Cursor to hand to `stream()` / `streamWs()` as `from`. Empty string = no cursor available;
* just start a plain live stream.
*
* The server guarantees it was inside the replayable window when the response was produced.
* (The one thing it cannot cover is the time it takes you to connect: if that partition takes
* more than a full replay window of new messages in between, you still get a resync.)
*
* ⚠️ It is **not necessarily** the `id` of the last message in `messages`. The scan runs
* newest → oldest, so everything between the newest message and the top of the scanned range is
* already known to contain nothing matching — the cursor is advanced there on purpose, to leave
* the largest possible margin for the gap between "fetched history" and "connected".
*/
resume_from: string;
/**
* True when resuming from `resume_from` would skip messages. Two sources: the cursor had to be
* pulled back into the replayable window **and** the skipped range was not part of this scan
* (you paged further back than the replay window, or retention deleted the middle); or this was
* a whole-topic (no `room`) query on a multi-partition topic, where a single cursor cannot cover
* every partition. When it is false, everything skipped was scanned and verified empty — the
* flag does not fire "just to be safe".
*/
resume_gap: boolean;
/**
* Token for the next (older) page — pass it back as `before`. Empty string = nothing older.
* It enumerates every partition the scan covered (including the ones already exhausted), so a
* `while (cursor) { … }` loop is guaranteed to make progress, terminate, and never repeat a page.
*/
before: string;
/** How many Kafka messages the server examined (not how many it returned). */
scanned: number;
}
interface AuditEntry {

@@ -231,3 +314,4 @@ tenant_id: string;

offset: number;
key?: string;
/** The room it was published to (`publish(topic, body, { room })`); absent when published without one. */
room?: string;
value: string;

@@ -331,4 +415,15 @@ }

deleteTopic(name: string): Promise<void>;
/**
* Publishes one message. `opts.room` routes it to a room — the same room you subscribe to with
* `stream({ room })` and read back as `message.room` from `poll()` / `history()`. Messages in one
* room are ordered relative to each other; omit it and the message is spread across partitions
* and belongs to no room.
*
* (This option was called `key` before v0.2.0. One name, one meaning: `key` now only ever means
* a credential. Passing the old `opts.key` **throws** — the server still accepts the deprecated
* `?key=` during the migration window, so a silently dropped `opts.key` would publish to a
* random partition, return 200, and leave the room empty with no signal at all.)
*/
publish(topic: string, body: unknown, opts?: {
key?: string;
room?: string;
}): Promise<PublishResult>;

@@ -365,3 +460,3 @@ poll(topic: string, opts?: {

* `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`).
* rooms — enforced by the platform on publish (`?room`) and subscribe (`?room`).
*/

@@ -442,2 +537,66 @@ createKey(scope: string, opts?: {

/**
* Fetches the most recent messages of a topic (or one `room`) so a late joiner does not face a
* blank screen. Pair it with `stream()`: fetch history, render it, then resume the live stream
* from the cursor history handed you.
*
* ```ts
* const seen = new Set<string>();
* const page = await mm.history("chat", { room: "lobby", limit: 50 });
* for (const m of page.messages) { seen.add(m.id); render(m.value); }
*
* // Hand off to live. `resume_from` is guaranteed to have been replayable when it was issued.
* mm.stream("chat", (data) => render(data), undefined, {
* room: "lobby",
* from: page.resume_from || undefined,
* });
* ```
*
* **Dedupe by id.** Delivery is at-least-once and the resume window deliberately overlaps, so the
* same message can arrive from both history and the stream. Every `HistoryMessage.id` uses the
* exact same `<partition>-<offset>` format as the stream's event id, so one `Set<string>` covers
* both. (`stream()` already dedupes what *it* delivers; the overlap with history is yours to
* dedupe, because the SDK does not hold your messages.)
*
* **Two cursors, two meanings.**
* - `resume_from` → hand to `stream()`/`streamWs()` as `from`. The server guarantees it was
* inside the replayable window when the response was produced, so it does not cost you a
* resync. It is **not necessarily** the id of the last message in the page: the scan runs
* newest → oldest, so the cursor is advanced to the top of the scanned range on purpose
* (nothing matching lives there), which maximises how long you may take to connect.
* Check `resume_gap`: `true` means resuming really would skip messages.
* - `before` → hand back to `history()` to page further back. **Not** guaranteed replayable;
* passing it to `stream()` can cost you a resync round-trip. It enumerates every partition the
* scan covered, so a `while (cursor)` loop progresses, terminates, and never repeats a page.
*
* **The SDK deliberately does not persist anything** (no localStorage, no IndexedDB): where a
* cursor belongs — memory, localStorage, your own backend — is your app's decision, not the
* SDK's. Keep `resume_from` in whatever store fits, and pass it back as `opts.from` next time.
*
* **`complete: true` means the scan stopped because it satisfied your request** — it filled the
* page to `limit`, or it reached the bottom of what exists. Not "the page was filled to `limit`",
* and **not** "there is nothing older": a full page of 50 with thousands more behind it is also
* `complete: true`. **Use `before` to decide whether to keep paging, never `complete`** (`limit`
* is silently capped server-side, so `messages.length < limit` is not a reliable test either).
* `complete: false` means the scan was cut short by something other than your request, and
* `incomplete_reason` says by what: `budget` (the older messages are still in Kafka, this scan
* just did not reach them — page back with `before`; covers the bounded record scan, the per-page
* byte cap, and a stalled broker) or `retention` (the `since` bound you passed reaches further
* back than the platform still keeps; deleted for good — a plain "latest N" query never returns
* this). Under the hood this is a *bounded scan over Kafka*, not a separate history database, so
* how far back you can reach depends on your plan's retention.
*
* `opts.room` — only messages published to that room (the same room you passed to `publish`).
* Omit to get the whole topic. ⚠️ Same caveat as `stream()`: room is a routing filter, not
* server-enforced isolation.
* `opts.before` / `opts.since` — bounds. Each accepts an RFC3339 timestamp, epoch **milliseconds**,
* or (for `before`) the `before` token from a previous page. Epoch *seconds* are rejected with a
* 400 rather than silently answering about 1970 — pass a `Date` and the SDK converts it for you.
*/
history(topic: string, opts?: {
room?: string;
limit?: number;
before?: string | Date;
since?: string | Date;
}): Promise<HistoryPage>;
/**
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a

@@ -541,2 +700,2 @@ * stop function. The connection authenticates with a query key, because a browser cannot set

export { type APIKey, type AdjustResult, type AuditEntry, AuthError, type Billing, type Deposit, type DepositAddress, type DepositStatus, type FinanceChainAmount, type FinanceOverview, type FinanceTenant, type LedgerEntry, type Message, MsgMesh, MsgMeshError, type MsgMeshOptions, NotFoundError, type Overview, type Page, type PlanLimits, type Presence, type PublishResult, RateLimitError, type ReplayResult, type SchemaVersion, type Settings, type Tenant, type TokenResponse, type Topic, type TopicFunction, type UsageDebit, type UsageResponse, type UsageRow, ValidationError, type Webhook, errorFromResponse, errorFromStatus };
export { type APIKey, type AdjustResult, type AuditEntry, AuthError, type Billing, type Deposit, type DepositAddress, type DepositStatus, type FinanceChainAmount, type FinanceOverview, type FinanceTenant, type HistoryIncompleteReason, type HistoryMessage, type HistoryPage, type LedgerEntry, type Message, MsgMesh, MsgMeshError, type MsgMeshOptions, NotFoundError, type Overview, type Page, type PlanLimits, type Presence, type PublishResult, RateLimitError, type ReplayResult, type SchemaVersion, type Settings, type Tenant, type TokenResponse, type Topic, type TopicFunction, type UsageDebit, type UsageResponse, type UsageRow, ValidationError, type Webhook, errorFromResponse, errorFromStatus };

@@ -102,2 +102,5 @@ // src/errors.ts

}
function anchorParam(v) {
return v instanceof Date ? String(v.getTime()) : v;
}
function parseEventId(id) {

@@ -246,4 +249,20 @@ if (!id) return null;

}
/**
* Publishes one message. `opts.room` routes it to a room — the same room you subscribe to with
* `stream({ room })` and read back as `message.room` from `poll()` / `history()`. Messages in one
* room are ordered relative to each other; omit it and the message is spread across partitions
* and belongs to no room.
*
* (This option was called `key` before v0.2.0. One name, one meaning: `key` now only ever means
* a credential. Passing the old `opts.key` **throws** — the server still accepts the deprecated
* `?key=` during the migration window, so a silently dropped `opts.key` would publish to a
* random partition, return 200, and leave the room empty with no signal at all.)
*/
async publish(topic, body, opts = {}) {
const qs = opts.key ? `?key=${encodeURIComponent(opts.key)}` : "";
if ("key" in opts) {
throw new TypeError(
"publish opts.key was renamed to opts.room in @msgmesh/sdk v0.2.0 \u2014 pass { room } instead (silently ignoring it would publish to a random partition outside every room)"
);
}
const qs = opts.room ? `?room=${encodeURIComponent(opts.room)}` : "";
const res = await this.f(`${this.gw}/v1/topics/${encodeURIComponent(topic)}/messages${qs}`, {

@@ -351,3 +370,3 @@ method: "POST",

* `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`).
* rooms — enforced by the platform on publish (`?room`) and subscribe (`?room`).
*/

@@ -559,2 +578,74 @@ async createKey(scope, opts) {

/**
* Fetches the most recent messages of a topic (or one `room`) so a late joiner does not face a
* blank screen. Pair it with `stream()`: fetch history, render it, then resume the live stream
* from the cursor history handed you.
*
* ```ts
* const seen = new Set<string>();
* const page = await mm.history("chat", { room: "lobby", limit: 50 });
* for (const m of page.messages) { seen.add(m.id); render(m.value); }
*
* // Hand off to live. `resume_from` is guaranteed to have been replayable when it was issued.
* mm.stream("chat", (data) => render(data), undefined, {
* room: "lobby",
* from: page.resume_from || undefined,
* });
* ```
*
* **Dedupe by id.** Delivery is at-least-once and the resume window deliberately overlaps, so the
* same message can arrive from both history and the stream. Every `HistoryMessage.id` uses the
* exact same `<partition>-<offset>` format as the stream's event id, so one `Set<string>` covers
* both. (`stream()` already dedupes what *it* delivers; the overlap with history is yours to
* dedupe, because the SDK does not hold your messages.)
*
* **Two cursors, two meanings.**
* - `resume_from` → hand to `stream()`/`streamWs()` as `from`. The server guarantees it was
* inside the replayable window when the response was produced, so it does not cost you a
* resync. It is **not necessarily** the id of the last message in the page: the scan runs
* newest → oldest, so the cursor is advanced to the top of the scanned range on purpose
* (nothing matching lives there), which maximises how long you may take to connect.
* Check `resume_gap`: `true` means resuming really would skip messages.
* - `before` → hand back to `history()` to page further back. **Not** guaranteed replayable;
* passing it to `stream()` can cost you a resync round-trip. It enumerates every partition the
* scan covered, so a `while (cursor)` loop progresses, terminates, and never repeats a page.
*
* **The SDK deliberately does not persist anything** (no localStorage, no IndexedDB): where a
* cursor belongs — memory, localStorage, your own backend — is your app's decision, not the
* SDK's. Keep `resume_from` in whatever store fits, and pass it back as `opts.from` next time.
*
* **`complete: true` means the scan stopped because it satisfied your request** — it filled the
* page to `limit`, or it reached the bottom of what exists. Not "the page was filled to `limit`",
* and **not** "there is nothing older": a full page of 50 with thousands more behind it is also
* `complete: true`. **Use `before` to decide whether to keep paging, never `complete`** (`limit`
* is silently capped server-side, so `messages.length < limit` is not a reliable test either).
* `complete: false` means the scan was cut short by something other than your request, and
* `incomplete_reason` says by what: `budget` (the older messages are still in Kafka, this scan
* just did not reach them — page back with `before`; covers the bounded record scan, the per-page
* byte cap, and a stalled broker) or `retention` (the `since` bound you passed reaches further
* back than the platform still keeps; deleted for good — a plain "latest N" query never returns
* this). Under the hood this is a *bounded scan over Kafka*, not a separate history database, so
* how far back you can reach depends on your plan's retention.
*
* `opts.room` — only messages published to that room (the same room you passed to `publish`).
* Omit to get the whole topic. ⚠️ Same caveat as `stream()`: room is a routing filter, not
* server-enforced isolation.
* `opts.before` / `opts.since` — bounds. Each accepts an RFC3339 timestamp, epoch **milliseconds**,
* or (for `before`) the `before` token from a previous page. Epoch *seconds* are rejected with a
* 400 rather than silently answering about 1970 — pass a `Date` and the SDK converts it for you.
*/
async history(topic, opts = {}) {
const params = new URLSearchParams();
if (opts.room) params.set("room", opts.room);
if (opts.limit != null) params.set("limit", String(opts.limit));
if (opts.before != null) params.set("before", anchorParam(opts.before));
if (opts.since != null) params.set("since", anchorParam(opts.since));
const qs = params.toString();
const res = await this.f(
`${this.rt}/v1/topics/${encodeURIComponent(topic)}/history${qs ? `?${qs}` : ""}`,
{ headers: await this.authHeader() }
);
const page = await asJSON(res);
return { ...page, messages: page.messages ?? [] };
}
/**
* Receives messages in realtime over SSE (browser only — it relies on EventSource). Returns a

@@ -561,0 +652,0 @@ * stop function. The connection authenticates with a query key, because a browser cannot set

+1
-1
{
"name": "@msgmesh/sdk",
"version": "0.1.8",
"version": "0.2.0",
"description": "MsgMesh TypeScript SDK — publish / consume / realtime (SSE / WebSocket) / governance client for the multi-tenant event bus, universal across Node and the browser.",

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

+180
-9

@@ -75,6 +75,6 @@ # @msgmesh/sdk

A single topic can be split into multiple rooms (room = Kafka record key), decoupling "number
A single topic can be split into multiple rooms (a room is one partition key under the hood), decoupling "number
of rooms" from "number of topics". Two layers:
**① Routing** — publish with `publish(topic, body, { key: roomId })` to target a room, and
**① Routing** — publish with `publish(topic, body, { room: roomId })` to target a room, and
subscribe with the optional `room` (the fourth argument `opts`, same for `stream` / `streamWs`)

@@ -86,3 +86,3 @@ to receive only that room:

mq.streamWs("chat", (data) => console.log(data), undefined, { room: "room-42" });
await mq.publish("chat", { text: "hi" }, { key: "room-42" }); // publish to room-42
await mq.publish("chat", { text: "hi" }, { room: "room-42" }); // publish to room-42
```

@@ -118,3 +118,3 @@

subscribe (SSE/WS) must carry a `?room` within the allowed set (omitting it = wanting all rooms,
also 403); publish `?key` must be within the allowed set.
also 403); the publish `room` must be within the allowed set.

@@ -143,2 +143,96 @@ > ⚠️ **A room-scoped credential can only use realtime (SSE/WS) + `publish` to its rooms**; it

## History: don't leave late joiners staring at a blank screen
A live stream starts at "now". Someone who opens your chat room, support thread, or event feed
after the fact sees nothing. `history()` fetches the most recent messages, and hands you a cursor
to resume the live stream from — no gap, no duplicates.
```ts
const seen = new Set<string>();
const render = (id: string, value: string) => {
if (seen.has(id)) return; // at-least-once: history and the stream deliberately overlap
seen.add(id);
console.log(value);
};
// 1. Fetch the last 50 messages of this room (oldest → newest).
const page = await mm.history("chat", { room: "room-42", limit: 50 });
for (const m of page.messages) render(m.id, m.value);
// 2. Resume live from where history ended. `resume_from` was replayable when it was issued.
mm.stream("chat", (data) => console.log(data), undefined, {
room: "room-42",
from: page.resume_from || undefined,
});
```
**Two cursors, two meanings — this is the part people get wrong:**
| field | what it is for | guarantee |
| --- | --- | --- |
| `resume_from` | hand to `stream()` / `streamWs()` as `from` | it was inside the replayable window when the response was produced |
| `before` | hand back to `history()` for the previous page | **none** — never pass it to `stream()` |
Two details about `resume_from` that save debugging time later:
- It is **not necessarily** the `id` of the last message you received. The scan runs newest →
oldest, so the server already knows the range above your newest message holds nothing for this
room, and it advances the cursor there on purpose — that leaves the largest possible margin for
however long your app takes to open the stream.
- `resume_gap: true` means resuming *would* skip messages: you paged further back than the
replayable window, retention deleted the middle, or this was a whole-topic (no `room`) query on a
multi-partition topic. When it is `false`, nothing is skipped — the server only reports a gap it
actually has, it does not raise the flag "just to be safe".
Paging further back:
```ts
let cursor = page.before;
while (cursor) {
const older = await mm.history("chat", { room: "room-42", limit: 50, before: cursor });
older.messages.forEach((m) => render(m.id, m.value));
cursor = older.before; // "" = nothing older
}
```
That loop terminates: `before` enumerates every partition the scan covered, so each round strictly
moves back and no page is ever repeated.
**`complete: true` means the scan stopped because it satisfied your request** — it either filled
the page to `limit`, or it reached the bottom of what exists. It is **not** "the page was filled to
`limit`", and it is **not** "there is nothing older". A room with only 3 messages answers a
`limit: 50` query with `complete: true` and 3 messages; so does a busy room answering with a full
page of 50 while thousands more sit further back.
**To find out whether there is another page, read `before`, never `complete`.** A non-empty `before`
means more is reachable; `""` means you have reached the bottom. (`limit` is silently capped
server-side, so `messages.length < limit` is not a reliable end-of-history test either.)
`complete: false` means the scan was cut short by something other than your request, and
`incomplete_reason` says by what:
- `budget` — the older messages are still in Kafka, this scan just did not reach them; page back
with `before`. (Three server-side limits share this reason because the fix is the same: the
bounded record scan, the per-page byte cap, and a broker that stalled part-way.)
- `retention` — you passed a `since` bound that reaches further back than the platform still
keeps; that stretch has been deleted by your plan's retention period and paging will not bring
it back. You only ever see this when you asked for a specific older range: a plain "latest N"
query is always `complete: true`.
The difference that matters: `budget` is recoverable by paging, `retention` is not.
Timestamps in `before` / `since` are **epoch milliseconds** (or RFC3339). Passing epoch *seconds*
is rejected with `400` rather than silently answering about 1970 — pass a `Date` and the SDK gets
it right for you.
**What this is, and is not.** Under the hood this is a *bounded scan over the event log*, not a
separate history database. That has two consequences worth designing around: how far back you can
reach is capped by your plan's **retention period**, and passing `room` is dramatically cheaper
than scanning the whole topic (a room lives on a single partition). If you need audit-grade access
to messages older than your retention window, keep your own copy — a bounded scan cannot invent
data the log no longer holds.
**The SDK stores nothing.** Where a cursor belongs — memory, `localStorage`, your own backend — is
your app's decision, not the SDK's. Keep `resume_from` wherever fits and pass it back as `from`.
## Production configuration (must read)

@@ -188,3 +282,4 @@

- Send/receive: `publish` / `poll` / `subscribe` (polling) / `stream` (SSE, browser) / `streamWs`
(WebSocket, browser + Node ≥ 22) / `getPresence`
(WebSocket, browser + Node ≥ 22) / `getPresence` / `history` (recent messages + a cursor to
resume the live stream from)
- Keys: `listKeys` (returns `capabilities` / `name`) / `createKey` (accepts `scope` +

@@ -260,5 +355,5 @@ `capabilities`) / `deleteKey`

一個 topic 內可再切多個房間(room = Kafka record key),脫鉤「房間數」與「topic 數」。分兩層:
一個 topic 內可再切多個房間(底層就是一個分割鍵),脫鉤「房間數」與「topic 數」。分兩層:
**① 路由**——發佈時用 `publish(topic, body, { key: roomId })` 指定房間,訂閱時傳選用 `room`(第四參數 `opts`,`stream`/`streamWs` 皆同)只收該房間:
**① 路由**——發佈時用 `publish(topic, body, { room: roomId })` 指定房間,訂閱時傳選用 `room`(第四參數 `opts`,`stream`/`streamWs` 皆同)只收該房間:

@@ -268,3 +363,3 @@ ```js

mq.streamWs("chat", (data) => console.log(data), undefined, { room: "room-42" });
await mq.publish("chat", { text: "hi" }, { key: "room-42" }); // 發到 room-42
await mq.publish("chat", { text: "hi" }, { room: "room-42" }); // 發到 room-42
```

@@ -289,3 +384,3 @@

也可用 `createKey("key", { capabilities: [{ ops, topics, rooms }] })` 簽一把常駐 room-scoped 鍵。平台強制點:訂閱(SSE/WS)必須帶允許集內的 `?room`(不帶=想收全部房間,一樣 403);發佈的 `?key` 必須 ∈ 允許集。
也可用 `createKey("key", { capabilities: [{ ops, topics, rooms }] })` 簽一把常駐 room-scoped 鍵。平台強制點:訂閱(SSE/WS)必須帶允許集內的 `?room`(不帶=想收全部房間,一樣 403);發佈的 `room` 必須 ∈ 允許集。

@@ -300,2 +395,78 @@ > ⚠️ **room-scoped 憑證只能走即時(SSE/WS)+ 對其房間 publish**;**不能** `poll` / `consume` / DLQ。後者是整個 topic 的 firehose(consumer-group offset 會吃掉別房間、每房一 group = 讀取放大),無法乾淨 per-room 過濾,受限房間憑證一律 403(`use realtime SSE/WS ?room=`)。需要 poll/consume 時請改用不限房間的憑證。

## 歷史訊息:別讓晚到的人面對一片空白
即時串流是從「現在」開始的。晚一步打開聊天室、工單、事件流的人,看到的是空的。
`history()` 取回最近的訊息,並交給你一個游標,讓你**無縫**接上即時串流——不漏、不重複。
```ts
const seen = new Set<string>();
const render = (id: string, value: string) => {
if (seen.has(id)) return; // at-least-once:歷史與串流刻意有重疊,依 id 去重
seen.add(id);
console.log(value);
};
// 1. 取這個房間最近 50 則(由舊到新)。
const page = await mm.history("chat", { room: "room-42", limit: 50 });
for (const m of page.messages) render(m.id, m.value);
// 2. 從歷史的結尾接上即時。resume_from 在發出的當下必定可續傳。
mm.stream("chat", (data) => console.log(data), undefined, {
room: "room-42",
from: page.resume_from || undefined,
});
```
**兩個游標語意不同——這是最容易搞錯的地方:**
| 欄位 | 用途 | 保證 |
| --- | --- | --- |
| `resume_from` | 交給 `stream()` / `streamWs()` 的 `from` | 在回應產生的當下必定落在可續傳窗內 |
| `before` | 交回 `history()` 取更舊的一頁 | **沒有**——絕對不要拿去餵 `stream()` |
關於 `resume_from`,兩個之後會省你除錯時間的細節:
- 它**不一定等於**你收到的最後一則的 `id`。掃描是由新到舊的,伺服器已經知道「你最新那一則之上」
那段沒有這個房間的訊息,於是刻意把游標推到那裡——讓你的 app 從「拿到歷史」到「連上串流」
之間的容忍時間最大化。
- `resume_gap: true` 代表續傳**真的會**漏掉一段:你往回翻得比可續傳窗還舊、中間被保留期刪掉,
或這是多 partition 的全 topic(不帶 `room`)查詢。為 `false` 時就是沒有漏——
伺服器只回報真的存在的缺口,不會為了保守而亂舉旗。
往前翻頁:
```ts
let cursor = page.before;
while (cursor) {
const older = await mm.history("chat", { room: "room-42", limit: 50, before: cursor });
older.messages.forEach((m) => render(m.id, m.value));
cursor = older.before; // "" = 沒有更舊的了
}
```
這個迴圈保證會結束:`before` 會列出本次掃描的每一個 partition,故每一圈都嚴格往舊推進、
不會重複回同一頁。
**`complete: true` 代表「你要的範圍內拿得到的都在這一頁了」**,不是「裝滿了 `limit` 則」。
一個總共只有 3 則的房間查 `limit: 50`,回的就是 `complete: true` + 3 則。
只有 `complete: false` 才代表有東西沒給到,`incomplete_reason` 說明是哪一種:
- `budget` — 更舊的還在 Kafka 裡,只是這次沒掃到;帶 `before` 再翻一頁即可。
(三種伺服器端上限共用這個 reason,因為處置完全一樣:有界的則數掃描、單頁位元組上限、
以及 broker 一時卡住。)
- `retention` — 更舊的訊息已被你方案的保留期刪掉;再問也沒有了。
差別在可否補救:`budget` 翻頁就有,`retention` 沒了就是沒了。
`before` / `since` 的時間是 **epoch 毫秒**(或 RFC3339)。傳成 epoch **秒**會回 `400`,
而不是默默回答 1970 年的事——直接傳 `Date`,SDK 會幫你轉對。
**它是什麼、不是什麼**:底下是對事件日誌的**有界回掃**,不是一個獨立的歷史資料庫。有兩個
設計上要納入考量的結果:能回溯多遠受**方案保留期**約束;帶 `room` 遠比掃整個 topic 便宜
(一個 room 的訊息落在單一 partition)。若你需要稽核等級地存取保留期以外的訊息,請自行留存副本
——有界回掃變不出日誌裡已經沒有的資料。
**SDK 不替你保存任何東西**:游標該放哪裡(記憶體、`localStorage`、你自己的後端)是你的 app
的決定,不是 SDK 的。把 `resume_from` 放在合適的地方,下次當 `from` 傳回來即可。
## 生產環境設定(必讀)

@@ -302,0 +473,0 @@