brainy/src/integrations/sse/SSEIntegration.ts
David Snelling ca9129a924 chore(8.0): modernize toolchain + position Bun as a runtime
Consumer-invisible modernization pass — no public API or runtime-behavior
change; dist for the override-only files is byte-identical.

Toolchain:
- CI: GitHub Actions matrix — Node 22/24 (test:unit) + Bun latest (test:bun).
- engines: node ">=22" (was "22.x"), bun ">=1.1.0".
- tsconfig: isolatedModules + noImplicitOverride; add the 33 `override`
  modifiers the flag requires across storage/integrations/vfs/transaction.
- deps: @types/node ^22; add prettier; drop dead standard-version,
  @rollup/plugin-* and the redundant embedded eslintConfig (flat
  eslint.config.js is the active config — verified identical lint output).

paramValidation: replace the top-level `await import('node:os'/'node:fs')`
with static ESM imports. The top-level-await form poisoned the module graph;
static imports also drop the browser/edge fallback branches no supported
runtime reaches (8.0 is Node/Bun/Deno-only).

Bun positioning: recommend Bun as a runtime (`bun add` / `bun run`), which is
green (test:bun 8/8). Drop single-binary `bun build --compile` as a target —
native addons cannot embed into it, and Bun 1.3.10 has a `--compile` codegen
regression around top-level await. Rename the Bun test to bun-runtime-test.ts
and correct docs that overclaimed single-binary support.

Gates: typecheck 0, build 0, test:unit 1743/1743, test:bun 8/8.
2026-07-01 09:16:46 -07:00

452 lines
11 KiB
TypeScript

/**
* SSE (Server-Sent Events) Integration
*
* Provides real-time streaming of Brainy events to clients.
* Universal - works in all environments.
*/
import {
IntegrationBase,
HTTPIntegration,
StreamingIntegration
} from '../core/IntegrationBase.js'
import {
IntegrationConfig,
EventFilter,
BrainyEvent
} from '../core/types.js'
/**
* SSE integration configuration
*/
export interface SSEConfig extends IntegrationConfig {
/** Base path for SSE endpoint (default: '/events') */
basePath?: string
/** Heartbeat interval in ms (default: 30000) */
heartbeatInterval?: number
/** Maximum clients (default: 1000) */
maxClients?: number
/** Include full entity data in events (default: false) */
includeData?: boolean
}
/**
* SSE client connection
*/
interface SSEClient {
id: string
filter: EventFilter
send: (event: string, data: any, id?: string) => void
close: () => void
lastEventId?: string
connectedAt: number
}
/**
* SSE Integration
*
* Enables real-time streaming of Brainy changes via Server-Sent Events.
* Clients can subscribe to specific event types and receive updates instantly.
*
* Endpoint:
* - GET /events - SSE stream
*
* Query Parameters:
* - types: Entity types to subscribe to (noun,verb,vfs)
* - operations: Operations to subscribe to (create,update,delete)
* - nounTypes: Specific noun types (person,document,...)
* - since: Resume from sequence ID (Last-Event-ID)
*
* @example
* ```typescript
* const sse = new SSEIntegration({
* basePath: '/events',
* heartbeatInterval: 30000
* })
* await sse.initialize()
*
* // Client-side:
* const source = new EventSource('/events?types=noun&operations=create,update')
* source.onmessage = (event) => {
* const data = JSON.parse(event.data)
* console.log('Change:', data)
* }
* ```
*/
export class SSEIntegration
extends IntegrationBase
implements HTTPIntegration, StreamingIntegration
{
readonly name = 'sse'
port: number
basePath: string
private sseConfig: SSEConfig & {
enabled: boolean
basePath: string
heartbeatInterval: number
maxClients: number
includeData: boolean
}
private clients: Map<string, SSEClient> = new Map()
private heartbeatTimer?: ReturnType<typeof setInterval>
private clientIdCounter = 0
constructor(config?: SSEConfig) {
super(config)
this.sseConfig = {
enabled: config?.enabled ?? true,
basePath: config?.basePath ?? '/events',
heartbeatInterval: config?.heartbeatInterval ?? 30000,
maxClients: config?.maxClients ?? 1000,
includeData: config?.includeData ?? false,
rateLimit: config?.rateLimit,
auth: config?.auth,
cors: config?.cors
}
this.port = 0
this.basePath = this.sseConfig.basePath
}
protected async onStart(): Promise<void> {
// Subscribe to all Brainy events
this.subscribeToChanges({}, (event) => {
this.broadcastEvent(event)
})
// Start heartbeat
this.heartbeatTimer = setInterval(() => {
this.sendHeartbeat()
}, this.sseConfig.heartbeatInterval)
this.log('SSE integration started')
}
protected async onStop(): Promise<void> {
// Clear heartbeat
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
}
// Close all clients
for (const [_, client] of this.clients) {
client.close()
}
this.clients.clear()
this.log('SSE integration stopped')
}
/**
* Handle incoming HTTP request for SSE
*/
handleRequest(request: {
method: string
path: string
query: Record<string, string>
headers: Record<string, string>
}): {
status: number
headers: Record<string, string>
body: string | null
isSSE: boolean
} {
const { method, path, query, headers } = request
if (method !== 'GET') {
return {
status: 405,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Method not allowed' }),
isSSE: false
}
}
const relativePath = path.startsWith(this.basePath)
? path.slice(this.basePath.length)
: path
if (relativePath !== '' && relativePath !== '/') {
return {
status: 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Not found' }),
isSSE: false
}
}
// Parse filter from query params
const filter = this.parseFilter(query, headers)
// Return SSE headers - actual stream handling is done by the server
return {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no'
},
body: null,
isSSE: true
}
}
/**
* Register an SSE client (called by HTTP server)
*/
registerClient(
sendFn: (event: string, data: any, id?: string) => void,
closeFn: () => void,
query: Record<string, string>,
headers: Record<string, string>
): string {
if (this.clients.size >= this.sseConfig.maxClients) {
throw new Error('Maximum clients reached')
}
const clientId = `sse-${++this.clientIdCounter}-${Date.now()}`
const filter = this.parseFilter(query, headers)
const client: SSEClient = {
id: clientId,
filter,
send: sendFn,
close: closeFn,
lastEventId: headers['last-event-id'],
connectedAt: Date.now()
}
this.clients.set(clientId, client)
this.recordRequest()
// Send initial connection event
sendFn('connected', { clientId, filter }, clientId)
// Replay missed events if resuming
if (filter.since !== undefined) {
const missed = this.eventBus.getEventsSince(filter.since)
for (const event of missed) {
if (this.matchesFilter(event, filter)) {
sendFn('change', this.formatEvent(event), String(event.sequenceId))
}
}
}
this.log(`SSE client connected: ${clientId}`)
return clientId
}
/**
* Unregister an SSE client
*/
unregisterClient(clientId: string): void {
this.clients.delete(clientId)
this.log(`SSE client disconnected: ${clientId}`)
}
/**
* Get connected client count
*/
getClientCount(): number {
return this.clients.size
}
/**
* Stream implementation for StreamingIntegration interface
*/
stream(
filter: EventFilter,
callback: (event: any) => void
): { close: () => void } {
const subscription = this.eventBus.subscribe(filter, (event) => {
callback(this.formatEvent(event))
})
return {
close: () => subscription.unsubscribe()
}
}
/**
* Get routes
*/
getRoutes(): Array<{ method: string; path: string; description: string }> {
return [
{
method: 'GET',
path: this.basePath,
description: 'Server-Sent Events stream'
}
]
}
/**
* Get manifest
*/
override getManifest(): Record<string, any> {
return {
id: 'sse',
name: 'SSE Streaming',
version: '1.0.0',
description: 'Real-time event streaming via Server-Sent Events',
longDescription:
'Enables real-time streaming of Brainy changes to web clients, Google Sheets, and dashboards via standard Server-Sent Events.',
category: 'integration',
status: 'stable',
configSchema: {
type: 'object',
properties: {
enabled: { type: 'boolean', default: true },
basePath: { type: 'string', default: '/events' },
heartbeatInterval: { type: 'number', default: 30000 },
maxClients: { type: 'number', default: 1000 },
includeData: { type: 'boolean', default: false }
}
},
configDefaults: {
enabled: true,
basePath: '/events',
heartbeatInterval: 30000,
maxClients: 1000,
includeData: false
},
features: [
'Real-time streaming',
'Event filtering',
'Automatic reconnection support',
'Event replay on reconnect',
'Heartbeat keep-alive'
],
keywords: ['sse', 'streaming', 'real-time', 'events']
}
}
// Private methods
private parseFilter(
query: Record<string, string>,
headers: Record<string, string>
): EventFilter {
const filter: EventFilter = {}
// HTTP query strings are an untrusted boundary: narrow them to the typed
// filter fields directly. Unknown values are harmless — matchesFilter()
// compares with includes(), so they simply never match an event.
if (query.types) {
filter.entityTypes = query.types.split(',') as EventFilter['entityTypes']
}
if (query.operations) {
filter.operations = query.operations.split(',') as EventFilter['operations']
}
if (query.nounTypes) {
filter.nounTypes = query.nounTypes.split(',') as EventFilter['nounTypes']
}
if (query.verbTypes) {
filter.verbTypes = query.verbTypes.split(',') as EventFilter['verbTypes']
}
if (query.service) {
filter.service = query.service
}
// Resume from Last-Event-ID header or query param
const since = headers['last-event-id'] || query.since
if (since) {
filter.since = BigInt(since)
}
return filter
}
private matchesFilter(event: BrainyEvent, filter: EventFilter): boolean {
if (filter.entityTypes?.length && !filter.entityTypes.includes(event.entityType)) {
return false
}
if (filter.operations?.length && !filter.operations.includes(event.operation)) {
return false
}
if (filter.nounTypes?.length && event.nounType && !filter.nounTypes.includes(event.nounType)) {
return false
}
if (filter.verbTypes?.length && event.verbType && !filter.verbTypes.includes(event.verbType)) {
return false
}
if (filter.service && event.service !== filter.service) {
return false
}
return true
}
private formatEvent(event: BrainyEvent): any {
const formatted: any = {
id: event.id,
type: event.entityType,
operation: event.operation,
entityId: event.entityId,
timestamp: event.timestamp
}
if (event.nounType) formatted.nounType = event.nounType
if (event.verbType) formatted.verbType = event.verbType
if (event.service) formatted.service = event.service
if (this.sseConfig.includeData && event.data) {
formatted.data = event.data
}
return formatted
}
private broadcastEvent(event: BrainyEvent): void {
const eventId = String(event.sequenceId)
for (const [_, client] of this.clients) {
if (this.matchesFilter(event, client.filter)) {
try {
client.send('change', this.formatEvent(event), eventId)
} catch {
// Client disconnected, will be cleaned up
}
}
}
}
private sendHeartbeat(): void {
const timestamp = Date.now()
for (const [_, client] of this.clients) {
try {
client.send('heartbeat', { timestamp })
} catch {
// Client disconnected
}
}
}
}
/**
* Package export for @soulcraft/brainy-sse
*/
export const integration = {
name: 'sse',
version: '1.0.0',
description: 'Real-time event streaming via Server-Sent Events',
environments: ['node', 'browser', 'deno', 'cloudflare', 'bun'],
create: (brain: any, config?: SSEConfig) => new SSEIntegration(config),
defaultConfig: {
basePath: '/events',
heartbeatInterval: 30000,
maxClients: 1000
}
}