@rumbamove/sdk
v0.2.1
Published
The Rumba SDK — upload, transcode, publish and play, from a server or from an app
Maintainers
Readme
@rumbamove/sdk
The client for Rumba — video ingest, transcoding, signed distribution and a shorts feed.
bun add @rumbamove/sdk # or npm / pnpm / yarnWhich client you construct is which credential you hold
Rumba issues two kinds of API key, and the SDK builds a different client for each. That split is the whole design: the client you can ship in an app bundle has no write surface on it at all — not hidden, not guarded, absent — so putting a transcode call in a React Native screen fails to compile rather than failing code review.
| Constructor | Key | Reaches |
|---|---|---|
| createServerClient | rmb_sk_… | Everything: videos, uploads, clips, audio, namespaces, keys, jobs |
| createPublishableClient | rmb_pk_… | The feed, view events and share links |
| createPublicClient | none | The open feed and share links |
Prefixes are checked at construction, before a single request is made. A secret key
handed to createPublishableClient throws immediately — by the time a call could
fail, that key would already be in a bundle someone downloaded.
Server side
import { createServerClient } from '@rumbamove/sdk'
const rumba = createServerClient({
baseUrl: 'https://video.example.com',
secretKey: process.env.RUMBA_SECRET_KEY!,
})
const { video, upload } = await rumba.videos.create({
namespace: 'kinshasa-cinema',
title: 'Mbongo Tea',
visibility: 'public',
reference: 'film_8812', // your own id — the feed hands it back
})
await rumba.videos.upload(video.id, Bun.file('./mbongo.mp4').stream())Progress, and the honest kind
await rumba.videos.upload(video.id, file, {
onProgress: ({ ratio }) => bar.set(ratio),
})This counts bytes handed to the transport. On a fast local network that is close enough; over a phone connection the socket buffer runs several megabytes ahead of what has arrived, so the bar finishes early and then waits.
For anything where that matters — or anything big enough that a dropped connection hurts — use the resumable path, which reports what the server has actually acknowledged:
await rumba.videos.uploadResumable(video.id, file, {
chunkSize: 8 * 1024 * 1024,
onProgress: ({ bytes, total }) => bar.set(bytes / total!),
})Call it again with the same arguments after a failure. It asks the server where it is before sending anything, so resuming is not a second code path you have to write — and an offset mismatch mid-flight is recovered from rather than thrown.
The body must be sliceable (a Blob, a File, a typed array). A stream is not
resumable in any useful sense: recovering means re-reading bytes already
consumed, which is the one thing a stream cannot do.
uploadStatus and uploadChunk are there if you want to drive the tus loop
yourself.
Waiting for the transcode
const ready = await rumba.videos.waitUntilReady(video.id, {
onProgress: ({ stage, progress }) => console.log(stage, `${progress}%`),
})Polling, for scripts and CI. A failed transcode throws VIDEO::FAILED with the
pipeline's own message; one that simply ran long throws VIDEO::TIMEOUT — worth
telling apart, because the second is usually a queue backlog rather than a bad
file. For anything server-side and long-lived, take the webhook instead.
Pagination
for await (const video of rumba.videos.paginate())
…In an app
import { createPublishableClient } from '@rumbamove/sdk'
const rumba = createPublishableClient({
baseUrl: 'https://video.example.com',
publishableKey: 'rmb_pk_…',
})
const { results, prefetch, expiresAt } = await rumba.feed.query({
namespaces: openStores, // `[]` means none — not "everybody"
limit: 10,
candidates: 40, // over-fetch, re-rank locally, drop the rest
rank: 'trending',
})
await rumba.feed.recordViews(watched)Every URL in a feed response expires together at expiresAt. Refetch the page
rather than trying to re-mint anything.
prefetch is advice, not policy — apply it to your final list, after re-ranking
and dropping, or you spend the viewer's data pulling items you are about to
discard.
Webhooks
Verify before you trust. Both ways of getting this wrong are silent — comparing
signatures with === leaks where the first byte differs, and skipping the
timestamp check leaves every delivery replayable forever — and neither fails a
test, because the happy path passes either way.
import { verifyWebhook } from '@rumbamove/sdk'
const raw = await request.text() // the raw body, byte for byte
const event = await verifyWebhook({
secret: process.env.RUMBA_WEBHOOK_SECRET!, // from namespaces.create, once
body: raw,
signature: request.headers.get('x-rumba-signature') ?? '',
})
if (event.type === 'video.completed') …Parse after verifying, never before: the signature covers the exact bytes that
were sent, and JSON.parse followed by JSON.stringify reorders keys.
Throws WEBHOOK::SIGNATURE_MALFORMED, WEBHOOK::TIMESTAMP_STALE or
WEBHOOK::SIGNATURE_INVALID, so a handler can answer 400 and say which.
Errors
Failures arrive as RumbaError, carrying the server's own status code alongside
the HTTP one. Switch on status; the message is for humans.
import { RumbaError } from '@rumbamove/sdk'
try {
await rumba.videos.playbackToken(id)
} catch (error) {
if (error instanceof RumbaError && error.status === 'VIDEO::NOT_READY') { … }
}Idempotent calls retry a 429 or 5xx with full jitter, honouring Retry-After.
Uploads never retry — a stream cannot be read twice, and a second attempt would
send nothing and truncate the file.
In a browser
bun add @rumbamove/sdk vidstackimport '@rumbamove/sdk/web'
import '@rumbamove/sdk/web.css'<rumba-player src="https://video.example.com/w/aB3dK9xQ2mZ"></rumba-player>
<rumba-feed></rumba-feed>Importing the entry registers both custom elements — that side effect is the
point of it, so do not expect a tree-shaker to keep a bare import for you unless
your bundler honours sideEffects (they all do).
<rumba-player>'s whole public surface is one attribute pointing at a share
link. Everything behind it — the manifest, the storyboard and chapter tracks,
the poster, the engine — is ours to change, so swapping engines stays an
internal refactor rather than a breaking change for everyone who embedded it.
vidstack is an optional peer dependency. Only this entry needs it, so a
server-side install is never asked for a media engine it will not load. A
Rumba host also serves a prebuilt version of exactly these components at
/static/player.js, for pages with no bundler at all.
Making it yours
Three levels, and which one you need depends on how far your design is from a plain vertical feed.
Tokens, for colour, spacing and radius. Set them anywhere that cascades:
rumba-feed {
--rumba-scrim: none;
--rumba-radius-pill: 8px;
--rumba-slide-height: 70vh;
}There is no shadow root, so your own selectors reach every element too, and
web.css is a file you link or do not.
renderItem, for anything that is markup rather than a value — a rail of
action buttons, a brand row, a call to action, a play overlay. The element owns
the media; the overlay above it is entirely yours:
const feed = document.querySelector('rumba-feed')!
feed.renderItem = ({ item, active, escape }) => `
<nav class="rail">
<button data-call>Call</button>
<button data-share>Share</button>
</nav>
<footer class="bar">
<img src="${escape(item.poster ?? '')}" alt="">
<p>${escape(item.title ?? '')}</p>
<button data-order="${escape(item.reference ?? '')}">Order now</button>
</footer>
`escape is there because titles are written by whoever uploaded the video,
which is not necessarily you. The overlay ignores pointer events so a tap
reaches the video; anything interactive inside takes them back automatically.
renderState does the same for the loading, empty and error screens.
The controller, when the layout is not a vertical feed at all — a horizontal carousel, a grid, a native list, a React tree:
import { createFeedController } from '@rumbamove/sdk'
const feed = createFeedController({
load: cursor => rumba.feed.query({ namespaces, cursor: cursor ?? undefined }),
report: ids => rumba.feed.recordViews(ids),
})
feed.subscribe(state => {
// state.plan[i] is 'play' | 'prefetch' | 'warm' | 'hold' | 'detach'
draw(state)
})
await feed.start()It owns paging, the byte-budgeted prefetch window, the dwell gate, re-minting
before the URLs expire, and batching view events. It also tops up a first page
that already sits inside the load-more trigger — otherwise a short first page
leaves nothing to scroll to, so nothing moves, so nothing ever asks for page two
— and appends by id, because pagination is a moving window and a later page can
hand back something already on screen. Your code reads state.plan
and does what it says; it never decides what to prefetch or what counts as
watched. <rumba-feed> is a view over exactly this — you are not stepping off a
supported path, you are taking the same one without the markup.
createFeedController is in the core entry, not ./web: it is DOM-free, so
React Native and a server-rendered page can hold the same decisions.
The budget is counted in bytes rather than items, because two 8 MB shorts is not the same ask as two 400 KB ones, and only the byte count is what the viewer pays for.
Reporting views
rank: 'trending' is computed from view events, so a feed that never sends them
ranks on a signal nobody is feeding. POST /feed/events needs a credential, so
give the element a publishable key and it reports for you:
<rumba-feed key="rmb_pk_…"></rumba-feed>Safe in an attribute — that key is public the moment it deploys, and the server
fixes its scopes to feed:read and feed:events. Without one the feed is the
install's open catalogue and nothing is reported.
In React Native
bun add @rumbamove/sdk expo-videoimport { RumbaFeed } from '@rumbamove/sdk/react-native'
<RumbaFeed
load={cursor => rumba.feed.query({ namespaces, cursor: cursor ?? undefined })}
report={ids => rumba.feed.recordViews(ids)}
renderOverlay={({ item, active }) => <Caption item={item} active={active} />}
/>A paging FlatList over expo-video, and almost nothing else — paging, the
prefetch window, the dwell gate, re-minting and view events are the same
createFeedController the browser component uses. Two renderings of one set of
decisions, not two implementations of them.
renderOverlay rather than renderItem, because renderItem in React Native
already means "the whole row" and this is explicitly not that. The component owns
the player; everything drawn over it is yours.
For a layout the component does not do, take the hook:
import { useRumbaFeed } from '@rumbamove/sdk/react-native'
const feed = useRumbaFeed({ load: cursor => rumba.feed.query({ cursor: cursor ?? undefined }) })
// feed.plan[i] is 'play' | 'prefetch' | 'warm' | 'hold' | 'detach'Single videos from a share link:
<RumbaPlayer baseUrl="https://video.example.com" shareId="aB3dK9xQ2mZ" />Every URL behind a share link is presigned and short-lived, and a VOD playlist is
static — the player never re-fetches it. So RumbaPlayer mints a fresh token a
minute before the current one lapses and swaps the source in place, which is what
stops a feature-length film simply stopping. Tune with refreshMarginMs.
One honest difference from the web. expo-video has no preload call — a
player begins buffering the moment it is created, and releasing it is the only
way to stop. So native has three states where the browser has five: playing,
created, released. warm and prefetch are the same thing here, which makes the
effective window one item wider than in a browser. Lower prefetch.maxItems if
the viewer's data is the binding constraint.
react, react-native and expo-video are optional peer dependencies, so a
server-side install is never asked for a native player. Install expo-video
yourself — and install it with npx expo install expo-video rather than by
pinning a version, because the right one is decided by your Expo SDK, not by
this package.
This package is typechecked against the oldest expo-video it claims to
support, not the newest — expo-video is pinned in devDependencies at the
floor of the peer range for exactly that reason. Developing against the newest
is how a peer range becomes fiction: the range said >=2 for a release while
the build compiled against 57, and nothing noticed that version 2 could not
compile at all.
expo-video 3 or newer. Version 2 is not merely untested, it is wrong:
the feed sets fullscreenOptions, which does not exist before 3 — so a shorts
feed on expo-video 2 would silently allow fullscreen. Expo SDK 54 resolves to
~3.0.15, which is the floor this is verified against; SDK 55 and later use
Expo's unified versioning (55.x, 56.x, 57.x) and all satisfy >=3.
FlatList's render window is derived from prefetch.maxItems rather than fixed:
it unmounts rows outside itself, and unmounting a slide releases its player, so a
window narrower than the budget would silently overrule it.
Types
import type { FeedItem, VideoResource } from '@rumbamove/sdk/types'The same declarations the server is compiled against. Importable without pulling in the client, for the places where you model Rumba's resources in your own code and do not want the transport in that file.
Coverage
Every route the server mounts is reachable from the SDK — 34 of 34, checked by a test that reads both sides and compares them, so a new route without a client method fails the suite rather than surfacing as a support thread.
The unauthenticated surfaces (health(), playback.token(), watch.get()) are
on all three clients, because they take no credential.
Notes
- The core entry has zero dependencies.
fetchand nothing else — no Node built-ins, no DOM, no engine. The same file runs on Bun, Node, browsers, React Native and Cloudflare Workers.bun run verify:entrieswalks the emitted import graph and fails if that stops being true, which is what keeps./webfrom leaking into a Worker bundle over code the Worker would never run. - Inject
fetchto test without a network, or to route through a proxy. AbortSignalis accepted on every call, and is combined with the timeout rather than replacing it.
Licence
MIT
