Files
fusion/packages/dashboard/src/badge-pubsub.ts
gsxdsm a2aa0289a8 feat(FN-1745): scope WebSocket channels to project context for multi-project support
- Scope /api/tasks/:id/logs/stream SSE endpoint to resolved project context
- Make badge WebSocket/pubsub channels project-aware using scopeKey
- Fix terminal WebSocket project/service resolution with auth middleware
- Add cross-instance badge broadcast via BadgePubSub with projectId metadata
- Update AGENTS.md documentation for badge WebSocket project-scoping
- Add tests for project-scoped WebSocket and SSE endpoints
- Add tests for project detection in WebSocket upgrade handling
2026-04-14 08:13:25 -07:00

311 lines
9.3 KiB
TypeScript

import { EventEmitter } from "node:events";
import type { IssueInfo, PrInfo } from "@fusion/core";
/**
* Badge snapshot message envelope for shared pub/sub.
*
* This contract is used for cross-instance badge updates. Each message includes
* a sourceId (server instance identifier), projectId (for cross-project isolation),
* taskId, timestamp, and optional prInfo/issueInfo snapshot data.
*
* Explicit null values indicate a badge was removed; omitted fields mean no change
* to that badge type's data.
*/
export interface BadgePubSubMessage {
/** Unique identifier for the originating server instance (for deduplication) */
sourceId: string;
/** Project scope key for cross-project isolation (e.g., project ID or "default") */
projectId?: string;
/** Task identifier */
taskId: string;
/** ISO timestamp when the snapshot was captured */
timestamp: string;
/** PR badge snapshot data; null = badge cleared; omitted = no change */
prInfo?: PrInfo | null;
/** Issue badge snapshot data; null = badge cleared; omitted = no change */
issueInfo?: IssueInfo | null;
}
/**
* BadgePubSub adapter interface for cross-instance badge snapshot fan-out.
*
* Implementations must:
* - Validate incoming messages against the BadgePubSubMessage contract
* - Emit 'message' events with validated BadgePubSubMessage payloads
* - Ignore malformed messages without crashing
* - Support graceful shutdown via dispose()
*/
export interface BadgePubSub extends EventEmitter<BadgePubSubEvents> {
/** Publish a badge snapshot to the shared bus */
publish(message: BadgePubSubMessage): void | Promise<void>;
/** Start receiving messages from the shared bus */
start(): void | Promise<void>;
/** Stop receiving messages and clean up resources */
dispose(): void | Promise<void>;
}
export interface BadgePubSubEvents {
message: [BadgePubSubMessage];
error: [Error];
}
/** Factory function type for creating BadgePubSub instances */
export type BadgePubSubFactory = (options: BadgePubSubFactoryOptions) => BadgePubSub;
export interface BadgePubSubFactoryOptions {
/** Redis URL (when using Redis adapter) */
redisUrl?: string;
/** Pub/sub channel name (when using Redis adapter) */
channel?: string;
/** Server instance identifier (for deduplication) */
sourceId: string;
}
/**
* In-memory BadgePubSub adapter for single-instance deployments and testing.
*
* This adapter does not actually communicate across instances; it's useful for:
* - Local single-instance deployments without Redis
* - Testing multi-instance scenarios with injected shared state
*/
export class InMemoryBadgePubSub extends EventEmitter<BadgePubSubEvents> implements BadgePubSub {
private started = false;
private disposed = false;
publish(message: BadgePubSubMessage): void {
if (this.disposed) return;
// In single-instance mode, we immediately emit the message locally
// This allows tests to verify the publish/subscribe flow
setImmediate(() => {
if (!this.disposed) {
this.emit("message", message);
}
});
}
start(): void {
if (this.disposed) return;
this.started = true;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.started = false;
this.removeAllListeners();
}
}
/**
* Redis-backed BadgePubSub adapter for multi-instance deployments.
*
* Requires REDIS_URL environment variable or redisUrl option.
* Uses Redis pub/sub for cross-instance message delivery.
*
* Environment variables:
* - FUSION_BADGE_PUBSUB_REDIS_URL: Redis connection URL
* - FUSION_BADGE_PUBSUB_CHANNEL: Channel name (default: "fusion:badge-updates")
*/
export class RedisBadgePubSub extends EventEmitter<BadgePubSubEvents> implements BadgePubSub {
private subscriber: import("ioredis").Redis | null = null;
private publisher: import("ioredis").Redis | null = null;
private started = false;
private disposed = false;
private readonly redisUrl: string | undefined;
private readonly channel: string;
private readonly sourceId: string;
constructor(options: BadgePubSubFactoryOptions) {
super();
this.redisUrl = options.redisUrl ?? getRedisUrlFromEnv();
this.channel = options.channel ?? getChannelFromEnv();
this.sourceId = options.sourceId;
}
async start(): Promise<void> {
if (this.disposed || this.started) return;
const redisUrl = this.redisUrl;
if (!redisUrl) {
throw new Error("Redis URL is required for RedisBadgePubSub");
}
try {
const { Redis } = await import("ioredis");
this.subscriber = new Redis(redisUrl);
this.publisher = new Redis(redisUrl);
// Handle connection errors without crashing the process
this.subscriber.on("error", (err: Error) => {
this.emit("error", new Error(`Redis subscriber error: ${err.message}`));
});
this.publisher.on("error", (err: Error) => {
this.emit("error", new Error(`Redis publisher error: ${err.message}`));
});
// Subscribe to channel and handle messages
await this.subscriber.subscribe(this.channel);
this.subscriber.on("message", (_channel: string, message: string) => {
if (this.disposed) return;
const parsed = parseBadgePubSubMessage(message, this.sourceId);
if (parsed.ok) {
this.emit("message", parsed.value);
}
// Silently ignore malformed messages
});
this.started = true;
} catch (err) {
// Clean up on failure
await this.cleanupConnections();
throw err;
}
}
async publish(message: BadgePubSubMessage): Promise<void> {
if (this.disposed || !this.publisher || !this.started) return;
try {
const payload = JSON.stringify(message);
await this.publisher.publish(this.channel, payload);
} catch (err) {
// Emit error but don't crash - caller can decide to retry
this.emit("error", err instanceof Error ? err : new Error(String(err)));
}
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.started = false;
await this.cleanupConnections();
this.removeAllListeners();
}
private async cleanupConnections(): Promise<void> {
try {
if (this.subscriber) {
await this.subscriber.unsubscribe();
await this.subscriber.quit();
this.subscriber = null;
}
} catch {
// Ignore cleanup errors
}
try {
if (this.publisher) {
await this.publisher.quit();
this.publisher = null;
}
} catch {
// Ignore cleanup errors
}
}
}
/**
* Parse and validate a BadgePubSubMessage from a JSON string.
*
* Returns { ok: true, value } for valid messages.
* Returns { ok: false } for malformed messages (should be silently ignored).
*
* The sourceId check prevents processing our own echoed messages.
*
* @internal Exported for testing purposes
*/
export function parseBadgePubSubMessage(
json: string,
localSourceId: string
): { ok: true; value: BadgePubSubMessage } | { ok: false } {
try {
const parsed = JSON.parse(json) as Partial<BadgePubSubMessage>;
// Validate required fields
if (typeof parsed.sourceId !== "string" || parsed.sourceId.length === 0) {
return { ok: false };
}
// Ignore our own messages (prevent echo loops)
if (parsed.sourceId === localSourceId) {
return { ok: false };
}
if (typeof parsed.taskId !== "string" || parsed.taskId.length === 0) {
return { ok: false };
}
if (typeof parsed.timestamp !== "string" || parsed.timestamp.length === 0) {
return { ok: false };
}
// Validate prInfo if present (can be null, object, or omitted)
if (parsed.prInfo !== undefined && parsed.prInfo !== null) {
if (typeof parsed.prInfo !== "object") {
return { ok: false };
}
// Basic validation of required PrInfo fields
const pr = parsed.prInfo as Partial<PrInfo>;
if (typeof pr.url !== "string" || typeof pr.number !== "number") {
return { ok: false };
}
}
// Validate issueInfo if present (can be null, object, or omitted)
if (parsed.issueInfo !== undefined && parsed.issueInfo !== null) {
if (typeof parsed.issueInfo !== "object") {
return { ok: false };
}
// Basic validation of required IssueInfo fields
const issue = parsed.issueInfo as Partial<IssueInfo>;
if (typeof issue.url !== "string" || typeof issue.number !== "number") {
return { ok: false };
}
}
return {
ok: true,
value: parsed as BadgePubSubMessage,
};
} catch {
return { ok: false };
}
}
/**
* Create a BadgePubSub adapter based on environment configuration.
*
* If FUSION_BADGE_PUBSUB_REDIS_URL is set, returns a RedisBadgePubSub.
* Otherwise, returns an InMemoryBadgePubSub for local-only operation.
*/
export function createBadgePubSub(options: { sourceId: string }): BadgePubSub {
const redisUrl = getRedisUrlFromEnv();
if (redisUrl) {
return new RedisBadgePubSub({
redisUrl,
channel: getChannelFromEnv(),
sourceId: options.sourceId,
});
}
return new InMemoryBadgePubSub();
}
function getRedisUrlFromEnv(): string | undefined {
return process.env.FUSION_BADGE_PUBSUB_REDIS_URL;
}
function getChannelFromEnv(): string {
return process.env.FUSION_BADGE_PUBSUB_CHANNEL ?? "fusion:badge-updates";
}
export { getRedisUrlFromEnv, getChannelFromEnv };