npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

justonair

v0.4.0

Published

Official Node.js and TypeScript SDK for the JustOnAir live video API

Readme

JustOnAir for Node.js

The official TypeScript SDK for the JustOnAir live video API: create a stream, send video over RTMP or WHIP, and viewers watch through the hosted player or your own.

npm install justonair

Node 20 or newer. No dependencies. ESM and CommonJS. Also runs on Bun, Deno and edge runtimes.

Quick start

Create an API key in the dashboard under API keys, and set it as JOA_API_KEY.

import JustOnAir from 'justonair';

const joa = new JustOnAir(); // reads JOA_API_KEY

const stream = await joa.streams.create({ name: 'Town hall' });

// For OBS or ffmpeg. Returned only here: store it.
console.log(stream.rtmp_url, stream.stream_key);
// For a browser or any WebRTC encoder.
console.log(stream.whip_url);
// The hosted player: share the link or put it in an iframe.
console.log(stream.embed_url);

Send video to it (a test picture, no camera needed):

ffmpeg -re -f lavfi -i "testsrc2=size=1280x720:rate=30" -f lavfi -i "sine=frequency=440" \
  -c:v libx264 -preset veryfast -b:v 2500k -g 60 -keyint_min 60 -sc_threshold 0 -pix_fmt yuv420p \
  -c:a aac -b:a 128k -f flv "$RTMP_URL/$STREAM_KEY"

and open embed_url. The Quickstart has the details, including OBS settings.

Streams

const stream = await joa.streams.create({
  name: 'Yoga, Tuesday 18:00',
  profile: 'abr_basic',          // several qualities for viewers (default), or 'passthrough'
  max_resolution: 720,           // the tallest video you will send: 480, 720 (default), 1080 (trusted accounts)
  record: true,                  // an MP4 after the stream ends (default)
  recording_retention_days: 30,  // null keeps it until you delete it
  metadata: { class_id: 'c_42' },// your own JSON, never shown to viewers
  player: { show_name: true, max_height: 720, watch_minutes_limit: 60_000 },
});

await joa.streams.get(stream.id);        // status, ingest, recording, cost, viewers
await joa.streams.update(stream.id, { name: 'Yoga (moved)' });
await joa.streams.end(stream.id);        // ends a live stream, cancels a pending one

const page = await joa.streams.list({ status: 'live', limit: 20 });
for await (const s of joa.streams.listAll()) console.log(s.id, s.status);

await joa.streams.replaceKey(stream.id); // lost the key before going live
await joa.streams.playbackToken(stream.id, { expires_in: 600 }); // a short signed HLS URL
await joa.streams.viewers(stream.id);    // viewers per minute and by country

A stream waits up to 30 minutes for video (pending), then is live, and finally ended, expired (nothing connected in time) or cancelled. Field-by-field meaning: API reference.

Recordings

One MP4 of the top quality, ready a few minutes after the stream ends.

await joa.recordings.waitUntilReady(stream.id);              // polls; throws if it will never be ready
const { url } = await joa.recordings.download(stream.id);    // signed link, 1 hour by default
await joa.recordings.delete(stream.id);

Webhooks

Get stream.live, stream.ended, recording.ready, credit.low and more pushed to your server instead of polling.

const endpoint = await joa.webhooks.endpoints.create({
  url: 'https://example.com/webhooks/justonair',
  events: ['stream.live', 'stream.ended', 'recording.ready'], // leave out for every event
});
// endpoint.secret is whsec_…: store it as JOA_WEBHOOK_SECRET

Verify each delivery against the raw body, before any JSON parsing:

// Express
app.post('/webhooks/justonair', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await joa.webhooks.verify(req.body, req.headers); // uses JOA_WEBHOOK_SECRET
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(204); // answer within 10 s, then do the work
  if (event.type === 'recording.ready') await importRecording(event.data.id);
});
// Next.js route handler, or anything with a Fetch API Request
export async function POST(req: Request) {
  const event = await joa.webhooks.verify(await req.text(), req.headers);
  // …
  return new Response(null, { status: 204 });
}

verify throws WebhookVerificationError for a bad signature, a wrong secret, a message older than 5 minutes, or missing headers. It is the Standard Webhooks format, and verifyWebhook(body, headers, secret) is exported on its own too. More: joa.webhooks.endpoints.list / get / update / delete / rotateSecret / test / deliveries / retryDelivery.

Balance and usage

const usage = await joa.usage.get({ days: 30 });
console.log(usage.available_usd, usage.days.map((d) => [d.date, d.cost_usd]));

await joa.account.notifications.update({ low_balance_threshold_usd: '20.00' });

Amounts are decimal strings ("12.345000"), so nothing is lost to floating point.

The hosted player

joa.embed.iframe(stream.embed_url, { title: 'Town hall' }); // the <iframe> snippet
await joa.embed.get(stream.id); // the player's public read; needs no API key

Chat and reactions (beta)

Turn chat on per stream, then read and moderate it from your server. Details: Chat and reactions.

const stream = await joa.streams.create({ name: 'Friday show', chat: { enabled: true, reactions: true } });

// Every message once, oldest first, polling the feed (deleted, filtered and shadow-banned ones too).
for await (const m of joa.chat.watch(stream.id)) {
  console.log(m.nickname, m.text, m.country, m.visible ? '' : '(hidden)');
  if (/buy followers/i.test(m.text)) await joa.chat.ban(stream.id, { message_id: m.id, delete_messages: true });
}

await joa.chat.post(stream.id, { text: 'Welcome!' });            // with the Host badge
await joa.chat.update(stream.id, { slow_mode_seconds: 10 });     // also paused, pinned_message_id
await joa.chat.setWords(stream.id, ['spoiler']);

// Someone else moderates from the chat page, with no API key: send them the link (works once).
const { invite_url } = await joa.chat.inviteModerator(stream.id, { name: 'Mert', active_days: 7 });

// Only your sites may embed the player and chat, or post to it.
await joa.streams.update(stream.id, { player: { allowed_domains: ['example.com', '*.example.com'] } });

Building your own chat UI? The public calls need no API key and work in a browser (new JustOnAir() without a key):

const viewer = new JustOnAir();
const session = await viewer.embed.session(streamId);              // once per browser and stream
await viewer.embed.postMessage(streamId, { token: session.token, nickname: 'Ayşe', text: 'Merhaba!' });
const chat = await viewer.embed.chat(streamId);                    // or poll https://play.joacdn.com/api/embed/{id}/chat
await viewer.embed.react(streamId, session.token, { heart: 3 });   // batched; see reaction_sampling

To float reactions over your own video player (Video.js, hls.js, Shaka, Plyr, a YouTube iframe), see Reactions over your own player: a copy-paste script that takes your player's element or a selector such as '.video-wrapper'.

Errors

Every error is a JustOnAirError. When the API answers with an error you get an APIError subclass with the API's stable code: program against the code, show the message to people.

import { RateLimitError, InsufficientCreditError } from 'justonair';

try {
  await joa.streams.create();
} catch (err) {
  if (err instanceof RateLimitError && err.code === 'limit_pending_streams') {
    // err.details: { limit: 2, current: 2 }
  } else if (err instanceof InsufficientCreditError) {
    // add credit
  } else throw err;
}

| Class | Status | Codes | |---|---|---| | BadRequestError | 400, 422 | invalid_request, idempotency_key_reused | | AuthenticationError | 401 | unauthorized | | InsufficientCreditError | 402 | insufficient_credit | | PermissionDeniedError | 403 | account_pending, tenant_suspended | | NotFoundError | 404 | not_found | | ConflictError | 409 | stream_not_pending, stream_ended, recording_not_ready | | RateLimitError | 429 | limit_*, rate_limited | | InternalServerError | 5xx | no_capacity, … | | APIConnectionError / APITimeoutError | — | no answer |

All codes: Errors.

Retries, timeouts, idempotency

  • Network errors, 5xx (including no_capacity) and rate_limited are retried twice with backoff. Account limits (limit_*) are not: waiting a second doesn't lift them.
  • streams.create sends an Idempotency-Key for you, so a retry never makes a second stream. Pass your own ({ idempotencyKey }) to keep that true across restarts of your process, for example your own order or event id.
  • A POST without an idempotency key is never retried.
  • Every request times out after 30 s.
const joa = new JustOnAir({ maxRetries: 0, timeoutMs: 8_000 });
await joa.streams.create(params, { idempotencyKey: `event-${eventId}`, timeoutMs: 5_000, signal });

Configuration

| Option | Default | | |---|---|---| | apiKey | JOA_API_KEY | Not needed for embed and webhooks.verify. | | webhookSecret | JOA_WEBHOOK_SECRET | For webhooks.verify. | | baseUrl | JOA_BASE_URL, then https://api.justonair.com | | | timeoutMs | 30000 | Per attempt. | | maxRetries | 2 | | | fetch | global fetch | For tests or a proxy. | | defaultHeaders | | Sent with every request. |

Keep API keys on your server. The SDK refuses to start with a key in a browser, where anyone could read it and spend your credit (dangerouslyAllowBrowser: true overrides that).

Types

Every request and response is typed from the OpenAPI spec, and the types are exported:

import type { Stream, CreatedStream, WebhookEvent, Usage } from 'justonair';

Links

Docs · API reference · Pricing · For AI agents · Dashboard

Security reports: [email protected]. Everything else: issues or [email protected].

License

MIT