refactor(server): share event stream encoding (#36484)
This commit is contained in:
parent
5414697bd1
commit
7913c4a490
9 changed files with 483 additions and 102 deletions
90
packages/server/src/event-feed.ts
Normal file
90
packages/server/src/event-feed.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue