refactor(server): share event stream encoding (#36484)

This commit is contained in:
Kit Langton 2026-07-12 11:48:52 -04:00 committed by GitHub
commit 7913c4a490
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 483 additions and 102 deletions

View file

@ -0,0 +1,90 @@
export * as EventFeed from "./event-feed"
import { EventV2 } from "@opencode-ai/core/event"
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect"
export const SubscriberCapacity = 4_096
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
"EventFeed.SubscriberOverflow",
{ capacity: Schema.Int },
) {}
export class EncodingError extends Schema.TaggedErrorClass<EncodingError>()("EventFeed.EncodingError", {
eventID: EventV2.ID,
eventType: Schema.String,
cause: Schema.Defect(),
}) {}
export type Error = SubscriberOverflowError | EncodingError
export interface Interface {
readonly subscribe: Effect.Effect<Stream.Stream<string, Error>, never, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/server/EventFeed") {}
const encode = Schema.encodeUnknownSync(OpenCodeEvent)
export function frame(event: OpenCodeEvent) {
return `data: ${JSON.stringify(encode(event))}\n\n`
}
export const make = Effect.fn("EventFeed.make")(function* (
observe: (subscriber: EventV2.Subscriber) => Effect.Effect<EventV2.Unsubscribe>,
options?: { readonly capacity?: number; readonly encode?: (event: OpenCodeEvent) => string },
) {
const capacity = options?.capacity ?? SubscriberCapacity
const render = options?.encode ?? frame
const subscribers = new Set<Queue.Queue<string, Error>>()
const fail = (error: Error) =>
Effect.sync(() => {
const current = Array.from(subscribers)
subscribers.clear()
for (const subscriber of current) Queue.failCauseUnsafe(subscriber, Cause.fail(error))
})
const publish = Effect.fnUntraced(function* (event: EventV2.Payload) {
if (!isOpenCodeEvent(event)) return
if (subscribers.size === 0) return
const encoded = yield* Effect.try({
try: () => render(event),
catch: (cause) => new EncodingError({ eventID: event.id, eventType: event.type, cause }),
}).pipe(
Effect.catch((error) =>
Effect.logError("Failed to encode public event", {
eventID: error.eventID,
eventType: error.eventType,
cause: error.cause,
}).pipe(Effect.andThen(fail(error)), Effect.as(undefined)),
),
)
if (encoded === undefined) return
for (const subscriber of subscribers) {
if (Queue.offerUnsafe(subscriber, encoded)) continue
subscribers.delete(subscriber)
Queue.failCauseUnsafe(subscriber, Cause.fail(new SubscriberOverflowError({ capacity })))
}
})
const unsubscribe = yield* observe(publish)
yield* Effect.addFinalizer(() => unsubscribe)
return Service.of({
subscribe: Effect.acquireRelease(
Queue.dropping<string, Error>(capacity).pipe(Effect.tap((queue) => Effect.sync(() => subscribers.add(queue)))),
(queue) =>
Effect.sync(() => subscribers.delete(queue)).pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid),
).pipe(Effect.map(Stream.fromQueue)),
})
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
return yield* make(events.listen)
}),
)

View file

@ -26,6 +26,7 @@ import { CredentialHandler } from "./handlers/credential"
import { ProjectHandler } from "./handlers/project"
import { ProjectCopyHandler } from "./handlers/project-copy"
import { VcsHandler } from "./handlers/vcs"
import { EventFeed } from "./event-feed"
export const handlers = Layer.mergeAll(
HealthHandler,
@ -48,7 +49,7 @@ export const handlers = Layer.mergeAll(
FileSystemHandler,
CommandHandler,
SkillHandler,
EventHandler,
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
ShellHandler,
QuestionHandler,

View file

@ -1,44 +1,23 @@
import { EventV2 } from "@opencode-ai/core/event"
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Effect, Schema, Stream } from "effect"
import { Sse } from "effect/unstable/encoding"
import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
// Session execution emits dense event bursts; allow healthy SSE clients enough
// time to absorb one without weakening the bounded slow-subscriber failure.
const subscriberCapacity = 4_096
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)),
}
}
import { EventFeed } from "../event-feed"
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const feed = yield* EventFeed.Service
return handlers.handleRaw("event.subscribe", () =>
Effect.gen(function* () {
const connected = {
id: EventV2.ID.create(),
type: "server.connected",
data: {},
}
} as const
const output = Stream.unwrap(
Effect.gen(function* () {
// Acquiring the bounded stream installs its listener before readiness is observable.
const live = yield* EventV2.liveBounded(events, {
capacity: subscriberCapacity,
accept: isOpenCodeEvent,
})
return Stream.make(connected).pipe(Stream.concat(live))
}),
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
feed.subscribe.pipe(Effect.map((live) => Stream.make(EventFeed.frame(connected)).pipe(Stream.concat(live)))),
)
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
return HttpServerResponse.stream(
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),