MEDIAFUSE Developer Guide

mediafuse loads overlays into OBS from a manifest file. The manifest describes which plugins to run, what permissions they have, and how they're configured. Everything starts here.

Manifests

A manifest is a JSON document that tells mediafuse what to display:

{
  "v": 1,
  "plugins": [
    {
      "src": "https://example.com/my-data-plugin.js",
      "allowTypes": ["data"],
      "config": {
        "pollInterval": 5000
      }
    },
    {
      "src": "https://example.com/my-overlay.js",
      "allowTypes": ["overlay"],
      "name": "My Overlay"
    }
  ],
  "blocks": [],
  "config": {
    "brandName": "my-stream"
  }
}

Top-level fields:

Plugin entries:

The manifest is encrypted into the overlay URL so it can be loaded without authentication. You can generate an overlay URL from the dashboard's Setup page.

How It Works

  1. OBS loads the overlay URL, which contains the encrypted manifest
  2. The runtime decrypts the manifest and fetches each plugin from its src URL
  3. Each plugin registers as either a data or overlay type
  4. Data plugins poll external sources and post messages into the system
  5. Overlay plugins receive those messages and render them visually
  6. Messages flow through an SSE stream, so overlays update in real time

Building Plugins

Plugins are standalone ES modules loaded at runtime. Each plugin exports a default function that receives definePlugin:

export default (definePlugin) => definePlugin("my-plugin", setup);

definePlugin(name, setup) declares the plugin name and calls your setup function with a PluginContext.

Plugin Types

A plugin registers as one of two types:

Setup Function

The setup function receives { register, manifest }:

function setup({ register, manifest }) {
  const registered = register("overlay", {
    onCreate(ctx) {
      // ctx.container  - HTMLDivElement (overlay) or null (data)
      // ctx.config     - merged manifest config + plugin entry config
      // ctx.emit       - function to broadcast events (data plugins only)
    },
    onMessage(msg) {
      // msg.type - string or null
      // msg.data - the message payload
      // msg.timestamp - when the message was created
      // msg.expiresAt - when it expires, or null
    },
    onCommand(cmd) {
      // cmd.name - command name
      // cmd.data - command payload
    },
    onTick(dt) {
      // dt - milliseconds since last frame (requestAnimationFrame)
    },
    onResize({ width, height }) {
      // overlay container resized
    },
    onConfig(manifest) {
      // manifest updated
    },
    onDestroy() {
      // clean up timers, DOM, connections
    },
  });

  if (!registered) return; // type was denied by manifest
}

register returns false if the manifest's allowTypes array doesn't include the requested type.

Messages

Messages flow through the system as StoredMessage objects:

{
  type: "music",           // freeform string, or null
  data: {                  // arbitrary payload
    title: "Track Name",
    artist: "Artist"
  },
  timestamp: 1709900000,   // set by the server
  expiresAt: 1709900015    // or null for persistent messages
}

Data plugins post messages via the HTTP API:

await fetch(`/api/messages?channel=${channel}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    type: "music",
    data: { title: "Now Playing", artist: "Someone" },
    duration: 15,
  }),
});

Overlay plugins receive messages through their onMessage handler. The type field is freeform -- use it to distinguish between different kinds of messages (e.g. "music", "alert", "chat").

Lifecycle

  1. The runtime fetches each plugin module from its src URL
  2. The default export is called with definePlugin
  3. definePlugin calls your setup function with register and manifest
  4. register is called synchronously during setup -- the onCreate handler fires immediately
  5. After all plugins load, the tick loop starts if any plugin has an onTick handler
  6. Messages from data plugins and the SSE stream are broadcast to all onMessage handlers
  7. On teardown, onDestroy is called for each registration

Examples

Data Plugin

A data plugin that polls an API and posts messages:

function setup({ register: reg }) {
  let intervalId = null;
  let apiUrl = null;

  const registered = reg("data", {
    onCreate(ctx) {
      apiUrl = ctx.config.apiUrl;
      intervalId = setInterval(() => poll(ctx.config.channel), 5000);
    },
    onDestroy() {
      if (intervalId) clearInterval(intervalId);
    },
  });

  if (!registered) return;

  async function poll(channel) {
    const res = await fetch(apiUrl);
    if (!res.ok) return;
    const data = await res.json();

    await fetch(`/api/messages?channel=${encodeURIComponent(channel)}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ type: "status", data, duration: 10 }),
    });
  }
}

export default (definePlugin) => definePlugin("my-poller", setup);

Overlay Plugin

An overlay plugin that displays incoming messages:

function setup({ register: reg }) {
  let el = null;

  const registered = reg("overlay", {
    onCreate(ctx) {
      el = document.createElement("div");
      el.style.cssText = "position:fixed;bottom:1rem;right:1rem;color:#fff;";
      ctx.container.appendChild(el);
    },
    onMessage(msg) {
      if (!msg || !el) return;
      el.textContent = msg.data.title || "";
    },
    onDestroy() {
      el?.remove();
    },
  });

  if (!registered) return;
}

export default (definePlugin) => definePlugin("toast", setup);

TypeScript

Install the SDK for type definitions:

npm install mediafuse

Types are exported from the package:

import type {
  StoredMessage,
  MessageOf,
  PluginType,
  PluginContext,
  PluginHandlers,
  CreateContext,
  PluginManifest,
} from "mediafuse";

MessageOf<T> is a helper that narrows StoredMessage to require a non-null type and typed data:

type MusicData = { artist: string; title: string };
type MusicMessage = MessageOf<MusicData>;
// { type: string; data: MusicData; timestamp: number; expiresAt: number | null }