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

@clodocapeo/pulsar-client

v1.1.0

Published

Typed TypeScript client for Pulsar -- the headless broadcast engine bundled in Prism.

Readme

@clodocapeo/pulsar-client

npm Licence MIT Node ≥ 18

Typed TypeScript client for Pulsar, the headless broadcast engine forked from OBS Studio. Speaks obs-websocket v5 + Pulsar's pulsar:* vendor namespace.

This package is the client side only — it wraps obs-websocket-js, adds typed namespaces over the vendor extensions, and exposes a strongly-typed event surface. It does not ship the pulsar.exe binary; for that, install @clodocapeo/pulsar-bundle (or @clodocapeo/pulsar-bundle-full for browser sources / CEF / VLC support), which re-exports this entire client surface and adds a spawn() API.

Table of contents

When to use it

Use this package when you already have a Pulsar (or any obs-websocket v5 server, including OBS Studio with the obs-websocket plugin) running somewhere — locally, on another machine over LAN, in a container — and just want to talk to it.

Typical scenarios:

  • A browser tool / Electron renderer that drives a Pulsar instance spawned by another process.
  • A CLI utility that probes broadcast state, mutates bitrate, or toggles destinations.
  • A test harness that talks to a fake / mocked v5 server.
  • Anything that doesn't want the ~40 MB postinstall download from the pulsar-bundle* packages.

If you want Node to spawn pulsar.exe and own its lifecycle, use @clodocapeo/pulsar-bundle instead — it depends on this package and re-exports everything below, plus a spawn() API.

Install

npm install @clodocapeo/pulsar-client

ESM-only, Node ≥ 18. Tree-shakeable. No native dependencies (the underlying obs-websocket-js uses the platform WebSocket / ws package).

Quick start

import { PulsarClient } from "@clodocapeo/pulsar-client";

const pulsar = new PulsarClient();

await pulsar.connect({
  url: "ws://127.0.0.1:4455",
  password: process.env.PULSAR_WS_PASSWORD,
});

// 1. Multi-destination: encode once, fan out to N outputs.
const twitch = await pulsar.destinations.create({
  name: "Main",
  kind: "twitch",
  key: process.env.TWITCH_KEY!,
});
await pulsar.destinations.start(twitch.id);

// 2. React to bitrate adaptation events.
pulsar.on("bitrateAdjusted", (e) => {
  console.log(`bitrate -> ${e.bitrate} kbps (${e.reason}, drop_ratio=${e.dropRatio.toFixed(4)})`);
});

// 3. Mutate the encoder live.
await pulsar.video.setBitrate(4500);

// 4. Singleton local recording (independent of destinations).
await pulsar.record.start();
await new Promise((r) => setTimeout(r, 5_000));
const path = await pulsar.record.stop();
console.log(`recorded to ${path}`);

// 5. v5 baseline passthrough — anything obs-websocket v5 supports.
const ver = await pulsar.obs.call("GetVersion");
console.log(`obs ${ver.obsVersion}, ws ${ver.obsWebSocketVersion}`);

await pulsar.disconnect();

The credentials come from the PULSAR_READY sentinel that pulsar.exe prints on stdout at boot — see docs/PRISM-EMBEDDING.md for the full handshake. If you used @clodocapeo/pulsar-bundle's spawn(), the bundle has already parsed those for you.

API reference

PulsarClient

class PulsarClient extends TypedEventEmitter {
  // Public namespaces
  readonly obs:           OBSWebSocket;             // raw obs-websocket-js
  readonly destinations:  DestinationsNamespace;
  readonly video:         VideoNamespace;
  readonly adaptive:      AdaptiveNamespace;
  readonly record:        RecordNamespace;
  readonly stream:        StreamNamespace;

  // Lifecycle
  connect(opts?: ConnectOptions): Promise<void>;
  disconnect(): Promise<void>;
  isConnected(): boolean;

  // Typed events — see "Events" below
  on(event: PulsarEventName, listener: (e: PulsarEventMap[E]) => void): this;
  off(event: PulsarEventName, listener: (e: PulsarEventMap[E]) => void): this;

  // Escape hatch for vendor calls not yet typed
  callVendor<TReq, TRes extends { error?: string }>(
    requestType: string,
    requestData?: TReq,
  ): Promise<TRes>;
}

connect(options?)

interface ConnectOptions {
  /** Defaults to "ws://127.0.0.1:4455". */
  url?: string;
  /** obs-websocket auth password. Read from PULSAR_READY sentinel
   *  or from <pulsar-bin>/obs-websocket/config.json (server_password). */
  password?: string;
  /** Bitmask of obs-websocket EventSubscription flags. Defaults to
   *  0x7FF (all baseline event categories). */
  eventSubscriptions?: number;
}

Throws on auth failure / network error / already-connected. Use the underlying obs-websocket-js exception types if you need to branch on the failure mode.

disconnect()

Idempotent. Resolves once the WebSocket close frame has landed.

isConnected()

Snapshot. Updates on connect() resolve and on the ConnectionClosed v5 event.

callVendor(requestType, requestData?)

Low-level escape hatch — use this when a new pulsar:* request lands upstream but a typed wrapper hasn't been added to this package yet.

const resp = await pulsar.callVendor<{ id: string }, { started?: boolean }>(
  "StartDestination",
  { id: "abcd-1234" },
);
console.log(resp.started); // true

Throws PulsarNotConnectedError if disconnected, PulsarVendorError if the server returns a non-empty error field, and lets the underlying obs-websocket-js exceptions bubble up otherwise.

destinations namespace

Multi-destination first-class. One encoder pair fans out to N outputs. Each destination is identified by a server-generated UUID.

class DestinationsNamespace {
  list(): Promise<Destination[]>;
  create(input: CreateDestinationInput): Promise<Destination>;
  remove(id: string): Promise<boolean>;
  start(id: string): Promise<boolean>;
  stop(id: string): Promise<boolean>;
  startAll(): Promise<void>;
  stopAll(): Promise<void>;
}
interface Destination {
  id: string;
  name: string;
  kind: "rtmp_custom" | "vod_local" | "twitch";
  url: string;             // server-pinned for twitch
  enabled: boolean;        // last user intent
  active: boolean;         // obs_output_active(d.output)
}
interface CreateDestinationInput {
  kind: "rtmp_custom" | "vod_local" | "twitch";
  name?: string;           // defaults to the generated id
  url?: string;            // RTMP URL (rtmp_custom) or file path (vod_local)
                           // ignored for twitch (server picks the ingest)
  key?: string;            // required for rtmp_custom + twitch, unused for vod_local
}

Examples

// Twitch: server pins the closest ingest URL
const twitch = await pulsar.destinations.create({
  name: "Twitch",
  kind: "twitch",
  key: process.env.TWITCH_KEY!,
});
console.log(twitch.url);
// → rtmp://<region>.contribute.live-video.net/app/

// Custom RTMP: e.g. a co-host's private ingest
const rtmp = await pulsar.destinations.create({
  name: "Co-host",
  kind: "rtmp_custom",
  url: "rtmp://my.private.cdn/live",
  key: "stream-key",
});

// Local MP4 archive (concurrent with streaming)
const archive = await pulsar.destinations.create({
  name: "VOD",
  kind: "vod_local",
  url: "C:/recordings/my-stream.mp4",
});

// Start all three with one call (encode once, fan out)
await pulsar.destinations.startAll();

// Or one at a time — start() returns false if RTMP refused
const started = await pulsar.destinations.start(twitch.id);
if (!started) console.error("Twitch refused the connection");

// Always remove on shutdown — the server gracefully stops first
await pulsar.destinations.remove(twitch.id);
await pulsar.destinations.remove(rtmp.id);
await pulsar.destinations.remove(archive.id);

video namespace

Snapshot + live-mutate the encoder configuration.

class VideoNamespace {
  get(): Promise<VideoSettings>;
  set(patch: VideoSettingsPatch): Promise<VideoSettingsPatchResult>;
  setBitrate(videoKbps: number): Promise<VideoSettingsPatchResult>; // shortcut
}
interface VideoSettings {
  fps: number;
  width: number;
  height: number;
  videoBitrate: number;        // kbps
  videoRateControl: string;    // e.g. "CBR"
  videoKeyintSec: number;
  audioBitrate: number;        // kbps
}

interface VideoSettingsPatch {
  videoBitrate?: number;
  audioBitrate?: number;
}

interface VideoSettingsPatchResult {
  changed: boolean;
  videoBitrate?: number;
  audioBitrate?: number;
}

Limitations

  • fps, width, height are not mutable at runtime — they require a libobs obs_reset_video() which would interrupt every active output. Set them via PULSAR_FPS and PULSAR_RESOLUTION env vars at spawn instead. Trying to set them via this API returns a typed PulsarVendorError.
  • audioBitrate is only applied while the audio encoder is idle (no active stream / record). The server returns changed: false if it could not apply the value.

adaptive namespace

Snapshot + control the adaptive bitrate worker. The worker samples obs_output_get_frames_dropped every 2 s, scales bitrate within [floor, target], and emits pulsar:BitrateAdjusted events.

class AdaptiveNamespace {
  getState(): Promise<AdaptiveState>;
  setEnabled(enabled: boolean): Promise<boolean>;
  enable(): Promise<boolean>;   // shortcut
  disable(): Promise<boolean>;  // shortcut
}
interface AdaptiveState {
  enabled: boolean;
  targetKbps: number;        // bitrate the loop tries to maintain
  currentKbps: number;       // encoder's currently configured bitrate
  floorKbps: number;         // 30% of target by default; never drops below
  stableTicks: number;       // ticks without drops since last adjust
  adjustmentsTotal: number;  // cumulative adjustments since boot
  lastDeltaTotal: number;    // frames produced last sample window
  lastDeltaDropped: number;  // frames dropped last sample window
  lastDropRatio: number;     // lastDeltaDropped / max(1, lastDeltaTotal)
}

Disabling pauses the sampling loop; the encoder bitrate stays at whatever value the worker last applied. Re-enabling resets stableTicks to 0 so the loop re-warms before any climb attempt.

record namespace

Wraps the legacy frontend-stub recording output — the singleton, env-driven recorder pulsar-frontend-stub creates at boot. Distinct from the multi-destination API where vod_local destinations are also MP4 files but client-named.

The path is auto-resolved to <recordDir>/pulsar-<YYYYMMDD-HHMMSS>.mp4 by the server. recordDir defaults to <cwd>/recordings/; override at spawn via PULSAR_RECORD_DIR.

class RecordNamespace {
  start(): Promise<void>;
  stop(timeoutMs?: number): Promise<string>;   // resolves with the .mp4 path
  pause(): Promise<void>;
  resume(): Promise<void>;
  isActive(): Promise<boolean>;
}

stop() waits for the RecordStateChanged event reporting STOPPED (with outputPath) before resolving. Default timeout is 10 s — adjust if your trailer-write needs longer (high-bitrate, slow disk).

stream namespace

Wraps the legacy frontend-stub streaming output — the singleton PulsarStream rtmp_output that obeys the v5 StartStream / StopStream baseline. Stream Deck, Companion, and Streamer.bot all use this path.

class StreamNamespace {
  start(): Promise<void>;
  stop(): Promise<void>;
  isActive(): Promise<boolean>;
}

⚠️ StartStream succeeds on the wire even when no destination URL is configured — the underlying obs_output_start declines silently. To actually go live through this surface, configure a service via the v5 SetStreamServiceSettings request first, or use pulsar.destinations.create({ kind: "twitch", … }) + pulsar.destinations.start(id) instead. The multi-destination API is the recommended path.

v5 baseline passthrough

Anything obs-websocket v5 supports is reachable via pulsar.obs.call(...):

// Scene CRUD
await pulsar.obs.call("CreateScene", { sceneName: "Live" });
const scenes = await pulsar.obs.call("GetSceneList");

// Source / input CRUD
await pulsar.obs.call("CreateInput", {
  sceneName: "Live",
  inputName: "Webcam",
  inputKind: "dshow_input",
  inputSettings: { device_id: "..." },
});

// Audio
await pulsar.obs.call("SetInputVolume", {
  inputName: "Mic/Aux",
  inputVolumeDb: -3,
});

// Filters
await pulsar.obs.call("CreateSourceFilter", {
  sourceName: "Webcam",
  filterName: "Color Correction",
  filterKind: "color_filter_v2",
});

// Stats
const stats = await pulsar.obs.call("GetStats");
console.log(stats.cpuUsage, stats.activeFps, stats.outputSkippedFrames);

The full v5 reference: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md

Events

The client emits typed events translated from both the v5 baseline and the pulsar:* vendor namespace.

pulsar.on("bitrateAdjusted", (e) => {
  // Pulsar vendor event — the adaptive worker just changed bitrate.
  console.log(e.bitrate, e.target, e.floor, e.reason, e.dropRatio);
});

pulsar.on("recordStateChanged", (e) => {
  // v5 baseline event.
  // e.state ∈ STARTING / STARTED / STOPPING / STOPPED / PAUSED / RESUMED / RECONNECTING / RECONNECTED
  if (e.state === "STOPPED" && e.outputPath) console.log("recorded:", e.outputPath);
});

pulsar.on("streamStateChanged", (e) => {
  console.log(e.state);
});

pulsar.on("studioModeStateChanged", (e) => {
  console.log(e.enabled);
});

pulsar.on("connectionClosed", (e) => {
  console.log(`connection closed: code=${e.code} reason=${e.reason}`);
});

For events not yet wrapped by this client, listen on the underlying obs-websocket-js client directly: pulsar.obs.on("InputCreated", …).

Event payload shapes

| Event | Payload | |---|---| | bitrateAdjusted | { bitrate: number, target: number, floor: number, reason: "drops" \| "recovery", dropRatio: number } | | recordStateChanged | { state: OutputState, outputPath?: string } | | streamStateChanged | { state: OutputState } | | studioModeStateChanged | { enabled: boolean } | | connectionClosed | { code: number, reason: string } |

OutputState = "STARTING" | "STARTED" | "STOPPING" | "STOPPED" | "PAUSED" | "RESUMED" | "RECONNECTING" | "RECONNECTED"

Errors

Two custom error classes plus whatever obs-websocket-js / the OS network stack throws.

import { PulsarNotConnectedError, PulsarVendorError } from "@clodocapeo/pulsar-client";

| Class | When | |---|---| | PulsarNotConnectedError | Method called before connect() resolves. | | PulsarVendorError | Server returned a typed error field on a vendor request (validation failure, unsupported kind, etc.). Carries requestType + message. |

try {
  await pulsar.destinations.create({
    kind: "twitch",
    key: "",      // empty key is rejected server-side
  });
} catch (err) {
  if (err instanceof PulsarVendorError) {
    console.error(`vendor request "${err.requestType}" failed: ${err.message}`);
  } else {
    throw err;
  }
}

Other failures (network drop, auth rejection, malformed payload) bubble up as the underlying obs-websocket-js exceptions — see its error reference.

Types

Every public type is exported from the package root:

import type {
  AdaptiveState,
  BitrateAdjustedEvent,
  ConnectOptions,
  CreateDestinationInput,
  Destination,
  DestinationKind,
  OutputState,
  PulsarEventMap,
  PulsarEventName,
  RecordStateChangedEvent,
  StreamStateChangedEvent,
  StudioModeStateChangedEvent,
  VideoSettings,
  VideoSettingsPatch,
  VideoSettingsPatchResult,
} from "@clodocapeo/pulsar-client";

Reconnect strategy

This client does not auto-reconnect. The connectionClosed event fires on every disconnect (clean or abrupt); your application decides whether to reconnect, with what delay, and with what backoff.

A reasonable pattern when bundling pulsar-bundle:

import { spawn } from "@clodocapeo/pulsar-bundle";

let pulsar = await spawn();
pulsar.client.on("connectionClosed", async (e) => {
  console.warn(`pulsar disconnect (code=${e.code}): respawning...`);
  await pulsar.shutdown().catch(() => {});
  pulsar = await spawn();
  // Re-issue any state your app depends on (destinations, scenes, ...).
});

If you're talking to an external Pulsar / OBS instance instead, just re-call connect() with exponential backoff on connectionClosed.

Wire format

Pulsar's vendor handlers serialize obs_data_t fields with the snake_case names the libobs C API expects. This client maps to camelCase at its boundary (src/wire.ts). The mapping is the only place to touch when the server adds a field.

If you call pulsar.callVendor(...) directly, you see snake_case in both the request and response payloads.

Versioning

Tracks Pulsar's VERSION file in lockstep — 1.0.0 of this package matches pulsar.exe 1.0.0.

  • Patch — bug fixes, no surface change.
  • Minor — new typed wrapper over an additive pulsar:* request or event. Existing code keeps compiling.
  • Major — rename / removal of an existing pulsar:* surface, or a breaking change in the embedding contract.

Compatibility

| | | |---|---| | Node | ≥ 18 | | Module system | ESM only ("type": "module") | | Browser | Yes (with a bundler that resolves obs-websocket-js's WebSocket import) | | TypeScript | ≥ 5.0 — strict mode supported | | obs-websocket | v5 (handles the v5.0–v5.7 wire format range) |

The same client talks to OBS Studio with the obs-websocket plugin just as well as to Pulsar — every v5 baseline call works, only the pulsar:* namespace is missing on stock OBS (vendor calls return an unknown-vendor error, which surfaces as PulsarVendorError).

Development

git clone https://github.com/ZabLaboratory/Pulsar
cd Pulsar/packages/pulsar-client
npm install
npm test            # vitest with mocked obs-websocket server
npm test -- --watch
npm run build       # tsc -> dist/
npm run lint        # tsc --noEmit

The test suite uses vitest with a mock obs-websocket server — no real pulsar.exe needed. The fixtures live under tests/.

Licence

MIT.

This wrapper contains no libobs code and links nothing GPL — it speaks obs-websocket v5 over a WebSocket. The pulsar.exe engine it talks to is distributed separately under GPL-2.0-or-later, but the process boundary keeps the licences disjoint (mere aggregation, not derivative work).

If you bundle pulsar.exe alongside this client (via @clodocapeo/pulsar-bundle or your own packaging), read LICENSE-INVARIANTS.md on the Pulsar repo first — there are four non-negotiable invariants your application must honour to keep its own licence under the process boundary.