How Instatic Broadcasts Plugin Lifecycle Events via SSE: Real-Time Plugin State Management

Instatic uses a Server-Sent Events (SSE) pipeline to push real-time plugin lifecycle notifications—such as installed, updated, crashed, restarted, enabled, and disabled—from the server to every open admin tab, utilizing lazy client-side connections and server-side heartbeats for reliability.

The CoreBunch/Instatic repository implements a robust SSE-based event system to notify admin interfaces about plugin state changes without polling. This architecture ensures that multiple browser tabs maintain synchronized views of plugin installation, updates, crashes, and configuration changes. Understanding how Instatic's event system broadcasts plugin lifecycle events via SSE reveals a design pattern that balances real-time responsiveness with connection efficiency.

Server-Side SSE Stream Implementation

Route Handler and ReadableStream Setup

The server-side entry point resides in server/handlers/cms/plugins/events.ts, where the handlePluginEventsStream function manages GET requests to /admin/api/cms/plugins/events. This handler creates a long-living ReadableStream that maintains an open HTTP connection to the client.

When a connection is established, the handler immediately emits an initial ping event to confirm readiness:

// server/handlers/cms/plugins/events.ts
export function handlePluginEventsStream(req: Request): Response {
  if (req.method !== 'GET') return methodNotAllowed();

  const encoder = new TextEncoder();
  let closeStream: (() => void) | null = null;

  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      // …setup cleanup, heartbeat, lease…
      const unsubscribe = subscribePluginEvents(event =>
        controller.enqueue(encoder.encode(
          `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`
        ))
      );
      // initial ping
      controller.enqueue(encoder.encode(`event: ping\ndata: connected\n\n`));
    },
    cancel() { closeStream?.(); }
  });

  return new Response(stream, {
    status: 200,
    headers: {
      'content-type': 'text/event-stream',
      'cache-control': 'no-store',
      'x-accel-buffering': 'no',
    },
  });
}

Plugin Event Broadcasting

The handler registers with the central plugin event broadcaster via subscribePluginEvents, located in src/core/plugins/eventBroadcaster.ts. This subscription hub receives all plugin lifecycle events and forwards them to connected SSE streams. Each PluginEvent is serialized into the SSE wire format with the event kind as the event name and the JSON payload as the data.

Connection Maintenance and Cleanup

To prevent reverse proxy timeouts (such as those in Vite or Nginx), the stream emits a heartbeat comment (: heartbeat) every 30 seconds. The connection also enforces a lease timeout of 2 minutes, automatically tearing down the stream when the request aborts or the lease expires to prevent orphaned connections.

Client-Side EventSource Management

Lazy Singleton Connection

The client implementation in src/admin/pages/plugins/utils/pluginEventStream.ts maintains a singleton EventSource instance that connects to /admin/api/cms/plugins/events. The connection follows a lazy initialization pattern: it opens only when the first consumer subscribes via ensureConnected and closes when the last listener unsubscribes using disconnectIfIdle.

// src/admin/pages/plugins/utils/pluginEventStream.ts
let source: EventSource | null = null;
const listeners = new Set<(e: PluginEvent) => void>();

function ensureConnected() {
  if (source) return;
  source = new EventSource('/admin/api/cms/plugins/events', { withCredentials: true });
  for (const kind of PLUGIN_EVENT_KINDS) {
    source.addEventListener(kind, ev => {
      const data = safeParseValue(PluginEventSchema, JSON.parse((ev as MessageEvent).data));
      if (!data.ok) return console.warn('bad event payload', data.errors);
      listeners.forEach(l => l(data.value));
    });
  }
}

export function subscribePluginEvents(listener: (e: PluginEvent) => void): () => void {
  listeners.add(listener);
  ensureConnected();
  return () => {
    listeners.delete(listener);
    if (listeners.size === 0) source?.close();
  };
}

Schema Validation and Event Distribution

For every event kind defined in PLUGIN_EVENT_KINDS, the EventSource registers a specific listener. Incoming messages undergo safe parsing using safeParseValue(PluginEventSchema, …) to validate against the TypeBox schema. Valid events are dispatched to all registered consumers, while malformed payloads trigger console warnings without breaking the stream.

Event Schema and Supported Lifecycle Events

All plugin events conform to PluginEventSchema, defined in src/core/plugins/events/index.ts. The schema guarantees that every event includes at minimum a kind field (identifying the lifecycle stage), a pluginId, and a timestamp. The exported constant PLUGIN_EVENT_KINDS enumerates supported events including installed, updated, crashed, restarted, enabled, and disabled.

Consuming Plugin Events

Admin UI components consume events by importing subscribePluginEvents and registering callbacks. The function returns a cleanup function that automatically manages the underlying EventSource lifecycle:

import { subscribePluginEvents } from './pluginEventStream';

const stop = subscribePluginEvents(event => {
  if (event.kind === 'crash') {
    pushToast({ kind: 'error', title: 'Plugin Crashed', body: event.pluginId });
  }
});

// later, when the component unmounts
stop();

This pattern allows multiple independent components—including plugin lists, toast notifications, and navigation badges—to react to state changes while sharing a single HTTP connection.

Summary

  • Server-sent events provide a unidirectional stream from server/handlers/cms/plugins/events.ts to admin clients at /admin/api/cms/plugins/events.
  • The server emits heartbeat comments every 30 seconds and enforces a 2-minute lease to maintain connection health and prevent orphaned connections.
  • Lazy initialization in src/admin/pages/plugins/utils/pluginEventStream.ts ensures the EventSource connects only when needed and closes automatically when idle.
  • All events validate against PluginEventSchema and dispatch through a type-safe subscription API using subscribePluginEvents.
  • The system supports comprehensive lifecycle notifications including installation, updates, crashes, and configuration changes.

Frequently Asked Questions

What is the SSE endpoint URL for plugin events in Instatic?

The endpoint is located at /admin/api/cms/plugins/events relative to the admin base path. This route is handled by handlePluginEventsStream in server/handlers/cms/plugins/events.ts and returns a text/event-stream response with appropriate headers to disable buffering and caching.

How does Instatic prevent SSE connection timeouts?

The implementation sends a heartbeat comment (: heartbeat) every 30 seconds to keep the connection alive through reverse proxies like Vite and Nginx. Additionally, the server enforces a 2-minute lease timeout that automatically closes the stream if the connection becomes stale, preventing resource exhaustion from orphaned connections.

What happens when multiple admin tabs are open?

Each tab maintains its own independent EventSource connection to the server. The server-side subscribePluginEvents broadcaster in src/core/plugins/eventBroadcaster.ts distributes events to all connected streams, ensuring every open tab receives synchronized plugin lifecycle updates without requiring client-side coordination or broadcasting between tabs.

How are malformed SSE messages handled on the client?

The client-side pluginEventStream.ts uses safeParseValue(PluginEventSchema, …) to validate incoming JSON payloads against the TypeBox schema. If parsing fails, the system logs a console warning and skips the malformed event, ensuring that schema violations in one message do not disrupt the event stream or crash the subscription handlers.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →