Files
Codewalkers/src/server/trpc-adapter.ts
Lukas May e5d8dbb583 feat(20): add SSE streaming support and subscription procedures
Fix tRPC HTTP adapter to stream ReadableStream responses instead of
buffering (required for SSE). Create subscriptions module that bridges
EventBus domain events into tRPC async generator subscriptions using a
queue-based pattern. Add three subscription procedures: onEvent (all
events), onAgentUpdate (agent lifecycle), onTaskUpdate (task/phase).
2026-02-04 22:16:14 +01:00

150 lines
5.1 KiB
TypeScript

/**
* tRPC HTTP Adapter
*
* Handles tRPC requests over HTTP using node:http.
* Routes /trpc/* requests to the tRPC router.
*/
import type { IncomingMessage, ServerResponse } from 'node:http';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createContext } from '../trpc/index.js';
import type { EventBus } from '../events/index.js';
import type { AgentManager } from '../agent/types.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { PlanRepository } from '../db/repositories/plan-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
/**
* Options for creating the tRPC request handler.
*/
export interface TrpcAdapterOptions {
/** Event bus for inter-module communication */
eventBus: EventBus;
/** When the server started */
serverStartedAt: Date;
/** Number of managed processes */
processCount: number;
/** Agent manager for agent lifecycle operations (optional until full wiring) */
agentManager?: AgentManager;
/** Task repository for task CRUD operations */
taskRepository?: TaskRepository;
/** Message repository for agent-user communication */
messageRepository?: MessageRepository;
/** Initiative repository for initiative CRUD operations */
initiativeRepository?: InitiativeRepository;
/** Phase repository for phase CRUD operations */
phaseRepository?: PhaseRepository;
/** Plan repository for plan CRUD operations */
planRepository?: PlanRepository;
/** Dispatch manager for task queue operations */
dispatchManager?: DispatchManager;
/** Coordination manager for merge queue operations */
coordinationManager?: CoordinationManager;
/** Phase dispatch manager for phase queue operations */
phaseDispatchManager?: PhaseDispatchManager;
}
/**
* Creates a tRPC request handler for node:http.
*
* Converts IncomingMessage/ServerResponse to fetch Request/Response
* and delegates to the tRPC fetch adapter.
*
* @param options - Adapter options with context values
* @returns Request handler function
*/
export function createTrpcHandler(options: TrpcAdapterOptions) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
// Build full URL from request
const protocol = 'http';
const host = req.headers.host ?? 'localhost';
const url = new URL(req.url ?? '/', `${protocol}://${host}`);
// Read request body if present
let body: string | undefined;
if (req.method !== 'GET' && req.method !== 'HEAD') {
body = await new Promise<string>((resolve) => {
let data = '';
req.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
req.on('end', () => {
resolve(data);
});
});
}
// Convert headers to fetch Headers
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value) {
if (Array.isArray(value)) {
value.forEach((v) => headers.append(key, v));
} else {
headers.set(key, value);
}
}
}
// Create fetch Request
const fetchRequest = new Request(url.toString(), {
method: req.method,
headers,
body: body ?? undefined,
});
// Handle with tRPC fetch adapter
const fetchResponse = await fetchRequestHandler({
endpoint: '/trpc',
req: fetchRequest,
router: appRouter,
createContext: () =>
createContext({
eventBus: options.eventBus,
serverStartedAt: options.serverStartedAt,
processCount: options.processCount,
agentManager: options.agentManager,
taskRepository: options.taskRepository,
messageRepository: options.messageRepository,
initiativeRepository: options.initiativeRepository,
phaseRepository: options.phaseRepository,
planRepository: options.planRepository,
dispatchManager: options.dispatchManager,
coordinationManager: options.coordinationManager,
phaseDispatchManager: options.phaseDispatchManager,
}),
});
// Send response
res.statusCode = fetchResponse.status;
// Set response headers BEFORE streaming body
fetchResponse.headers.forEach((value, key) => {
res.setHeader(key, value);
});
// Stream body if it's a ReadableStream (SSE subscriptions), otherwise buffer
if (fetchResponse.body) {
const reader = fetchResponse.body.getReader();
const pump = async () => {
while (true) {
const { done, value } = await reader.read();
if (done) {
res.end();
return;
}
res.write(value);
}
};
pump().catch(() => res.end());
} else {
const responseBody = await fetchResponse.text();
res.end(responseBody);
}
};
}