A lightweight, isomorphic TypeScript client for the XStreamRoll platform.
It handles authentication, stream CRUD, real-time event publishing, and
an extensible HTTP transport (interceptors + retry) with zero runtime
dependencies — both the high-level StreamingClient and the
low-level HttpClient are built on the platform-global fetch.
Issues #391, #393, and #395 — this README was audited against the actual SDK implementation. Endpoint references, dependency claims, and the Quick Start have all been verified to compile and run. Issues opened against this README should update the audit alongside the source change.
- Installation
- Quick start
- Configuration
- Authentication
- Streams
- Real-time events
- HTTP transport
- Pagination
- Types
- Browser usage
- Testing helpers
- API reference — auto-generated by
typedoc - Compatibility matrix
- Versioning
- Publishing
# npm (workspaces-aware)
npm install @stellar/streaming-sdk
# pnpm
pnpm add @stellar/streaming-sdk
# yarn
yarn add @stellar/streaming-sdk@stellar/streaming-sdk@1.x targets ES2020 and has zero runtime
dependencies. The HTTP layer uses the platform-global fetch
(available in Node ≥ 18, all evergreen browsers, Bun, Deno, and
Cloudflare Workers). Dev-only dependencies are ts-jest and jest
for the test suite, plus openapi-typescript and typedoc for
type/reference regeneration.
The full Quick Start below compiles against the public
StreamingClient surface and exercises the parts of the API that
already exist on the server. Stream CRUD currently lives on the
API server (consumed by the web app); the SDK provides a
getStreamStatus shortcut and publishEvent for clients that emit
live event traffic.
import { StreamingClient, HttpClient, ApiError } from "@stellar/streaming-sdk"
const client = new StreamingClient({
env: "production", // or "staging" | "development", or a custom baseUrl
})
// 1. Log in
const { user, accessToken, refreshToken } = await client.login(
"alice@example.com",
"super-secret-password",
)
// user: { id, email, displayName, role, createdAt, updatedAt }
// 2. Publish an event to an existing stream.
// `clientId` is auto-filled with a stable per-instance id; pass
// `eventType` and `data` and the client adds a timestamp for you.
try {
await client.publishEvent({
streamId: "stream_abc",
eventType: "viewer:joined",
data: { viewerId: "user_42" },
})
} catch (err) {
if (err instanceof ApiError) {
console.error(`API ${err.statusCode}: ${err.message}`)
} else {
throw err
}
}
// 3. Read the current status of a stream you own.
const stream = await client.getStreamStatus("stream_abc")
console.log(`stream ${stream.id} -> status=${stream.status}, visibility=${stream.visibility}`)
// 4. Tear down
await client.logout()Stream CRUD: the SDK is intentionally thin so it can mirror the API surface 1:1. To create, list, or update streams from a CLI, an SDK consumer, or a server-to-server integration, hit the API directly with
HttpClient(see HTTP transport). The web app already does this through its Next.js API routes.
StreamingClient accepts a StreamConfig:
| Field | Type | Notes |
|---|---|---|
env |
"development" | "staging" | "production" |
Named preset. Resolves to a well-known base URL. |
baseUrl |
string |
Explicit base URL. Overrides env and the legacy apiUrl. |
apiUrl |
string (deprecated) |
Legacy field. Kept for backwards compatibility. |
clientId |
string |
Identifier attached to published events. Defaults to a timestamp. |
Resolution order: baseUrl → env → apiUrl → development.
The full URL presets are:
| Env | Base URL |
|---|---|
development |
http://localhost:3001 |
staging |
https://staging-api.xstreamroll.io |
production |
https://api.xstreamroll.io |
const loginTokens = await client.login(email, password)
const registerTokens = await client.register({
email: "alice@example.com",
password: "super-secret-password",
displayName: "Alice",
})
// tokens: { user, accessToken, refreshToken }
// Refresh an expired access token
const freshTokens = await client.refreshToken()
// freshTokens: { user, accessToken, refreshToken }StreamingClient keeps the active tokens on the instance and:
- attaches
Authorization: Bearer <accessToken>to every outbound request, and - transparently refreshes the access token on a
401response (using the stored refresh token), then retries the original request once.
Call await client.logout() to invalidate the session server-side
and drop the local tokens.
Token storage: tokens are kept in memory only. Persist them with
localStorage/sessionStorage/ a secure cookie if you need them to survive a page reload.
// Read the current status of a stream you own.
const stream = await client.getStreamStatus("stream_abc")Stream CRUD (POST /streams, GET /streams, PATCH /streams/:id,
DELETE /streams/:id) lives on the API server. The web app consumes
it directly. From SDK code that wants to bypass the web app and call
the API itself, use HttpClient:
import { HttpClient } from "@stellar/streaming-sdk"
const http = new HttpClient("https://api.xstreamroll.io")
http.addRequestInterceptor((cfg) => {
const headers: Record<string, string> = {
...(cfg.headers as Record<string, string> | undefined),
Authorization: `Bearer ${loginTokens.accessToken}`,
}
return { ...cfg, headers }
})
const created = await http.post("/streams", {
name: "My new stream",
visibility: "private",
})
const createdJson = (await created.json()) as { id: number }
const listing = await http.get("/streams?ownerOnly=true&page=1&limit=20")
const listingJson = (await listing.json()) as {
data: Array<{ id: number; name: string; visibility: "public" | "private" }>
total: number
page: number
limit: number
}GET /streams returns streams visible to the caller:
| Caller | Result |
|---|---|
| Any authenticated user | Public streams + their own private streams (default) |
visibility=public |
Only public streams |
visibility=private |
Only the caller's own private streams |
ownerOnly=true |
Only streams owned by the caller |
This mirrors the API documented at http://localhost:3001/docs once
the API server is running.
await client.publishEvent({
streamId: "stream_abc",
eventType: "data",
data: { foo: "bar" },
})eventType is one of the union members exported as StreamEventType:
"stream:started" | "stream:stopped" | "stream:error" | "viewer:joined" | "viewer:left" | "data". The client auto-fills
clientId and a timestamp (ISO 8601) before posting.
WebSocket subscriptions — coming soon. The API broadcasts real-time lifecycle events over Socket.IO (see the API service's
streams.gateway.ts), but the SDK does not yet expose typed subscribe helpers. Usesocket.io-clientdirectly against the same API host until those helpers land. Client-side wiring in the web app lives inhooks/useStreamSocket.ts.
HttpClient is a small, fetch-based wrapper that:
- merges
baseUrl+path, - runs request/response interceptors in registration order,
- retries transient failures (408, 425, 429, 5xx) with exponential backoff + jitter.
import { HttpClient } from "@stellar/streaming-sdk"
const http = new HttpClient("https://api.xstreamroll.io", {
maxAttempts: 5,
baseDelayMs: 250,
maxDelayMs: 5_000,
})
const res = await http.request("/streams/abc")
const json = await res.json()const authHandle = http.addRequestInterceptor((cfg) => ({
...cfg,
headers: { ...cfg.headers, Authorization: `Bearer ${token}` },
}))
const metricsHandle = http.addResponseInterceptor((res) => {
metrics.record(`/ -> ${res.status}`)
return res
})
// later
http.removeInterceptor(authHandle)
http.removeInterceptor(metricsHandle)Request interceptors run in registration order, receive the full
RequestInit & { url }, and may return a new config. Response
interceptors run after fetch resolves, may be async, and may
replace the response (e.g. to transparently re-issue on 401).
The retry helper (withRetry) is generic and exported separately:
import { withRetry } from "@stellar/streaming-sdk"
await withRetry(() => callFlakyApi(), {
maxAttempts: 4,
baseDelayMs: 100,
maxDelayMs: 2_000,
jitterMs: 50,
onRetry: (err, attempt, delay) => console.warn("retry", attempt, err, delay),
})The HttpClient uses the helper internally; pass { enabled: false }
to opt out per client.
When the retry budget is exhausted the client throws
HttpRequestError, which carries:
- the last error message,
- the last
Response(cloned, so it can be read after the throw), - the number of attempts made.
The high-level StreamingClient translates non-2xx responses into
ApiError (also exported from the SDK), exposing statusCode,
message, and a typed response payload.
Pagination in the API is driven by ?page= and ?limit= query
parameters (max limit=100). Lists return a PaginatedResponse<T>
payload:
interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
}There is no SDK
paginateStreams()helper — the SDK is thin on purpose. Drive pagination explicitly viaHttpClient.get():const res = await http.get("/streams?page=2&limit=50") const { data, total, page, limit } = (await res.json()) as PaginatedResponse<Stream> const hasMore = page * limit < total
List endpoints often require callers to drive the cursor by hand —
re-computing page, incrementing until total is collected. The SDK
exposes a small async iterator helper that does that loop for you and
yields each item exactly once across the entire result set:
import { StreamingClient } from "@stellar/streaming-sdk"
const client = new StreamingClient({ env: "production" })
// Iterate every public stream, page by page, as an async iterable.
for await (const stream of client.paginateAll<Stream>("/streams", {
visibility: "public",
limit: 100,
})) {
console.log("got stream", stream.id)
}
// Or collect the full list synchronously once the iterator drained.
const allStreams = await client.paginateAll("/streams").toArray()paginateAll exposes an async iterator (use for await … of) and the
standard AsyncIterable helpers — .toArray(), .map(fn),
.filter(fn). It stops when the page * limit envelope reaches
total, so it works even if the server omits the (legacy) hasMore
flag from the response.
The SDK ships full type definitions. The most useful are:
Stream,CreateStreamDto,UpdateStreamDto,StreamVisibility— stream CRUD shapes.StreamEvent,StreamEventRecord,StreamEventType— event shapes.AuthTokens,User,CreateUserDto,UpdateUserDto— auth shapes.PaginatedResponse<T>,PaginationParams— list helpers.ApiError,ApiErrorResponse,ValidationError— error shapes.
All types are re-exported from the package root. Stream visibility
(StreamVisibility = "public" | "private") was added so SDK
callers can read the wiring on a Stream returned by the API; the
matching server-side enforcement landed via database migration
2026080501_add_stream_visibility.
The SDK ships both a CJS build (dist/index.js) and an ESM
build (dist-esm/index.js) and exposes them through the standard
"exports" field in package.json. Tree-shaking bundlers (Vite,
Webpack 5+, Rollup, esbuild, Turbopack) automatically pick the
ESM bundle; older bundlers and Node require() resolve to the CJS
build. No polyfills are required for evergreen browsers — the SDK
uses the native fetch and crypto.subtle APIs that have shipped in
every evergreen browser for several years.
import { StreamingClient } from "@stellar/streaming-sdk"
const client = new StreamingClient({
baseUrl: "https://api.my-deployment.example.com",
})For SSR environments (Next.js, Remix, etc.) avoid constructing the client at module scope; lazy-construct it inside the request handler so that auth tokens can be read from the incoming request.
The retry behaviour and the HTTP layer are both fully unit-tested. To
test consumers, the recommended approach is to inject a mock
HttpClient rather than the full StreamingClient:
import { HttpClient } from "@stellar/streaming-sdk"
const mock = new HttpClient("http://test")
// add request/response interceptors to assert on outbound callsFor retry timing in tests, inject a custom sleep:
new HttpClient("http://x", { sleep: async () => {} })For mutation testing — to detect undertested code paths masked by high line coverage — the SDK ships a Stryker config:
npm run test:mutation --workspace=xstreamroll-sdkOnce typedoc is installed, generate a browsable HTML reference
from the public TSDoc:
# from the workspace root
npm install # pulls typedoc (devDependency)
npm run docs:api --workspace=@stellar/streaming-sdk
# …or directly:
cd xstreamroll-sdk && npm install && npm run docs:apiThis emits a static site at xstreamroll-sdk/docs/api/ (gitignored).
The output mirrors the categories used in this README so navigation
matches what you see here.
The SDK is intentionally versioned with the API semver so the
contract below is easy to reason about. Use npx tsc --noEmit
against your consumer if you want type-level confirmation before
bumping a stack.
| SDK version | API version | Status | Notes |
|---|---|---|---|
1.x |
1.x |
✅ Current | Adds StreamVisibility (public/private, migration 2026080501); GET /streams is visibility-filtered per caller. |
0.x |
0.x |
Pre-visibility; streams.visibility was undefined and ignored. |
|
1.x |
0.x |
❌ Mismatch | SDK will send visibility payloads the legacy API silently drops. Pin SDK to 0.x. |
0.x |
1.x |
❌ Mismatch | API returns streams.visibility but the SDK type does not declare it; cast to unknown and validate manually or upgrade. |
Rule of thumb: bump SDK and API together. If you absolutely need to mix versions, install the SDK that matches the oldest API you target and gate the visibility-aware endpoints behind a runtime version check.
- Follows semver.
- Public API is whatever the package
index.tsre-exports. - Breaking changes bump the major version and are announced in the release notes.
Publishing is done by the maintainers via the release.yml workflow
(.github/workflows/release.yml). To cut a release:
- Bump the version in
xstreamroll-sdk/package.json(semver). - Update the changelog.
- Open a PR titled
chore(sdk): release vX.Y.Z. - Once merged and CI is green, push the matching tag:
git tag sdk/vX.Y.Z && git push origin sdk/vX.Y.Z.
The release workflow builds the package (CJS + ESM) and publishes it to the configured registry.