feat(sdk): expose live event stream (#34098)
This commit is contained in:
@@ -1,20 +1,29 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||||
import { Api } from "@opencode-ai/server/api"
|
import { ClientApi } from "../src/contract"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
const contract = compile(ClientApi, {
|
||||||
groupNames: { "server.session": "sessions" },
|
groupNames: { "server.session": "sessions", "server.event": "events" },
|
||||||
})
|
})
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
[
|
[
|
||||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
|
||||||
write(
|
write(
|
||||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
emitPromise(contract, {
|
||||||
|
outputTypes: {
|
||||||
|
"events.subscribe": {
|
||||||
|
name: "OpenCodeEventEncoded",
|
||||||
|
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||||
|
),
|
||||||
|
write(
|
||||||
|
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
||||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||||
"@opencode-ai/client/LocationMiddleware",
|
"@opencode-ai/client/LocationMiddleware",
|
||||||
@@ -17,3 +17,5 @@ const Api = makeDefaultApi({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const SessionGroup = Api.groups["server.session"]
|
export const SessionGroup = Api.groups["server.session"]
|
||||||
|
export const EventGroup = Api.groups["server.event"]
|
||||||
|
export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup)
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { Session } from "@opencode-ai/schema/session"
|
|||||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||||
|
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
import { Effect, Stream, Schema } from "effect"
|
import { Effect, Stream, Schema } from "effect"
|
||||||
import { Sse } from "effect/unstable/encoding"
|
import { Sse } from "effect/unstable/encoding"
|
||||||
import { HttpClientError } from "effect/unstable/http"
|
import { HttpClientError } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../contract"
|
import { ClientApi } from "../contract"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||||
|
|
||||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
|
||||||
|
|
||||||
const mapClientError = <E>(error: E) =>
|
const mapClientError = <E>(error: E) =>
|
||||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||||
@@ -210,7 +208,20 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
|||||||
message: Endpoint0_16(raw),
|
message: Endpoint0_16(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
const Endpoint1_0 = (raw: RawClient["server.event"]) => () =>
|
||||||
|
Stream.unwrap(
|
||||||
|
raw["event.subscribe"]({}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) })
|
||||||
|
|
||||||
|
const adaptClient = (raw: RawClient) => ({
|
||||||
|
sessions: adaptGroup0(raw["server.session"]),
|
||||||
|
events: adaptGroup1(raw["server.event"]),
|
||||||
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
SessionsInterruptOutput,
|
SessionsInterruptOutput,
|
||||||
SessionsMessageInput,
|
SessionsMessageInput,
|
||||||
SessionsMessageOutput,
|
SessionsMessageOutput,
|
||||||
|
EventsSubscribeOutput,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { ClientError } from "./client-error"
|
import { ClientError } from "./client-error"
|
||||||
|
|
||||||
@@ -373,6 +374,13 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
|
||||||
|
sse<EventsSubscribeOutput>(
|
||||||
|
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"
|
||||||
|
|
||||||
export type JsonValue =
|
export type JsonValue =
|
||||||
| null
|
| null
|
||||||
| boolean
|
| boolean
|
||||||
@@ -1666,3 +1668,5 @@ export type SessionsMessageOutput = {
|
|||||||
readonly time: { readonly created: number }
|
readonly time: { readonly created: number }
|
||||||
}
|
}
|
||||||
}["data"]
|
}["data"]
|
||||||
|
|
||||||
|
export type EventsSubscribeOutput = OpenCodeEventEncoded
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
|
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Workspace } from "@opencode-ai/schema/workspace"
|
|||||||
import { Api } from "@opencode-ai/server/api"
|
import { Api } from "@opencode-ai/server/api"
|
||||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
import { HttpApi } from "effect/unstable/httpapi"
|
||||||
import { SessionGroup } from "../src/contract"
|
import { EventGroup, SessionGroup } from "../src/contract"
|
||||||
|
|
||||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||||
expect(AgentV2.ID).toBe(Agent.ID)
|
expect(AgentV2.ID).toBe(Agent.ID)
|
||||||
@@ -32,6 +32,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
|||||||
expect(CorePrompt).toBe(Prompt)
|
expect(CorePrompt).toBe(Prompt)
|
||||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
||||||
|
expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier)
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
expect(Project.ID.global).toBe("global")
|
expect(Project.ID.global).toBe("global")
|
||||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||||
|
|||||||
@@ -15,6 +15,50 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
|||||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(
|
||||||
|
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const events = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||||
|
const durable = events[1]
|
||||||
|
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
|
||||||
|
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
|
||||||
|
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on Effect protocol decode failures", async () => {
|
||||||
|
const httpClient = HttpClient.make((request) =>
|
||||||
|
Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(`data: {"type":"server.connected"}\n\n`, {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const error = await Effect.gen(function* () {
|
||||||
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
|
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
|
||||||
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("ClientError")
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||||
const historyQueries: Array<Record<string, string>> = []
|
const historyQueries: Array<Record<string, string>> = []
|
||||||
let historyPage = 0
|
let historyPage = 0
|
||||||
|
|||||||
@@ -17,6 +17,35 @@ test("sessions.get returns the wire projection", async () => {
|
|||||||
expect(result.time.created).toBe(1_717_171_717_000)
|
expect(result.time.created).toBe(1_717_171_717_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("events.subscribe exposes the Promise event stream wire projection", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(
|
||||||
|
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||||
|
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const events = []
|
||||||
|
for await (const event of client.events.subscribe()) events.push(event)
|
||||||
|
|
||||||
|
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||||
|
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("events.subscribe terminates on malformed Promise SSE data", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||||
|
name: "ClientError",
|
||||||
|
reason: "MalformedResponse",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods use the public HTTP contract", async () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
let historyPage = 0
|
let historyPage = 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as EventV2 from "./event"
|
export * as EventV2 from "./event"
|
||||||
|
|
||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
import { and, asc, eq, gt, inArray } from "drizzle-orm"
|
import { and, asc, eq, gt, inArray } from "drizzle-orm"
|
||||||
@@ -107,6 +107,11 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||||
|
"EventV2.SubscriberOverflow",
|
||||||
|
{ capacity: Schema.Int },
|
||||||
|
) {}
|
||||||
|
|
||||||
export const define = Event.define
|
export const define = Event.define
|
||||||
export const versionedType = Event.versionedType
|
export const versionedType = Event.versionedType
|
||||||
|
|
||||||
@@ -144,6 +149,20 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||||
|
|
||||||
|
export const allBounded = (events: Interface, capacity: number) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||||
|
const unsubscribe = yield* events.listen((event) =>
|
||||||
|
Queue.offer(queue, event).pipe(
|
||||||
|
Effect.flatMap((accepted) =>
|
||||||
|
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
|
||||||
|
return Stream.fromQueue(queue)
|
||||||
|
})
|
||||||
|
|
||||||
export interface LayerOptions {
|
export interface LayerOptions {
|
||||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
@@ -285,6 +285,69 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("notifies global listeners only after a durable event is committed", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const aggregateID = EventV2.ID.create()
|
||||||
|
const observed = new Array<{ id: string; seq: number }>()
|
||||||
|
yield* events.listen((event) =>
|
||||||
|
event.type !== SyncMessage.type
|
||||||
|
? Effect.void
|
||||||
|
: db
|
||||||
|
.select({ id: EventTable.id, seq: EventTable.seq })
|
||||||
|
.from(EventTable)
|
||||||
|
.where(eq(EventTable.id, event.id))
|
||||||
|
.get()
|
||||||
|
.pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.tap((row) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (row) observed.push(row)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.asVoid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" })
|
||||||
|
if (!event.durable) throw new Error("Expected durable event metadata")
|
||||||
|
|
||||||
|
expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const consuming = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const slowStream = yield* EventV2.allBounded(events, 1)
|
||||||
|
const fastStream = yield* EventV2.allBounded(events, 8)
|
||||||
|
const slow = yield* slowStream.pipe(
|
||||||
|
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.publish(Message, { text: "one" })
|
||||||
|
yield* Deferred.await(consuming)
|
||||||
|
yield* events.publish(Message, { text: "two" })
|
||||||
|
yield* events.publish(Message, { text: "overflow" })
|
||||||
|
const last = yield* events.publish(Message, { text: "still delivered" })
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
|
||||||
|
const slowExit = yield* Fiber.await(slow)
|
||||||
|
expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError)
|
||||||
|
expect(Array.from(yield* Fiber.join(fast))).toEqual([
|
||||||
|
expect.objectContaining({ data: { text: "one" } }),
|
||||||
|
expect.objectContaining({ data: { text: "two" } }),
|
||||||
|
expect.objectContaining({ data: { text: "overflow" } }),
|
||||||
|
last,
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves observer interruption", () =>
|
it.effect("preserves observer interruption", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ export function emitEffectImported(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitPromise(contract: Contract): Output {
|
export function emitPromise(
|
||||||
|
contract: Contract,
|
||||||
|
options?: {
|
||||||
|
readonly outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>
|
||||||
|
},
|
||||||
|
): Output {
|
||||||
const groups = contract.groups
|
const groups = contract.groups
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
|
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
|
||||||
@@ -238,7 +243,7 @@ export function emitPromise(contract: Contract): Output {
|
|||||||
return {
|
return {
|
||||||
operations: operations(groups),
|
operations: operations(groups),
|
||||||
files: [
|
files: [
|
||||||
{ path: "types.ts", content: renderPromiseTypes(groups) },
|
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) },
|
||||||
{
|
{
|
||||||
path: "client-error.ts",
|
path: "client-error.ts",
|
||||||
content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
|
content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
|
||||||
@@ -408,7 +413,10 @@ function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Reado
|
|||||||
return { imports: [...new Set(imports)], source }
|
return { imports: [...new Set(imports)], source }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
function renderPromiseTypes(
|
||||||
|
groups: ReadonlyArray<Group>,
|
||||||
|
outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
|
||||||
|
) {
|
||||||
const types = new Map<SchemaAST.AST, string>()
|
const types = new Map<SchemaAST.AST, string>()
|
||||||
const typeOf = (schema: Schema.Top, decoded = false) => {
|
const typeOf = (schema: Schema.Top, decoded = false) => {
|
||||||
const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
|
const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
|
||||||
@@ -453,13 +461,15 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
|||||||
})
|
})
|
||||||
.join("; ")
|
.join("; ")
|
||||||
const successSchema = endpoint.successes[0]
|
const successSchema = endpoint.successes[0]
|
||||||
const success = typeOf(
|
const success =
|
||||||
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ??
|
||||||
? successSchema.sseMode === "data"
|
typeOf(
|
||||||
? streamEncodedDataSchema(successSchema)
|
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
||||||
: successSchema.events
|
? successSchema.sseMode === "data"
|
||||||
: successSchema,
|
? streamEncodedDataSchema(successSchema)
|
||||||
)
|
: successSchema.events
|
||||||
|
: successSchema,
|
||||||
|
)
|
||||||
return [
|
return [
|
||||||
...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
||||||
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
||||||
@@ -470,7 +480,8 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
|||||||
const json = operations.includes("JsonValue")
|
const json = operations.includes("JsonValue")
|
||||||
? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
|
? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
|
||||||
: ""
|
: ""
|
||||||
return [json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
|
||||||
|
return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ describe("HttpApiCodegen.generate", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("allows Promise outputs to use an authoritative imported wire type", () => {
|
||||||
|
const contract = compileContract(
|
||||||
|
api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })),
|
||||||
|
)
|
||||||
|
const output = emitPromise(contract, {
|
||||||
|
outputTypes: {
|
||||||
|
"session.events": {
|
||||||
|
name: "EventWire",
|
||||||
|
import: 'import type { EventWire } from "./event-wire"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||||
|
|
||||||
|
expect(types).toContain('import type { EventWire } from "./event-wire"')
|
||||||
|
expect(types).toContain("export type SessionEventsOutput = EventWire")
|
||||||
|
})
|
||||||
|
|
||||||
test("emits an Effect client against an imported authoritative API", () => {
|
test("emits an Effect client against an imported authoritative API", () => {
|
||||||
const output = emitEffectImported(
|
const output = emitEffectImported(
|
||||||
compileContract(
|
compileContract(
|
||||||
|
|||||||
@@ -27,13 +27,44 @@ const Event = Schema.Struct({
|
|||||||
data: Schema.Unknown,
|
data: Schema.Unknown,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
async function* eventStream(body: ReadableStream<Uint8Array>) {
|
||||||
const value = await reader.read()
|
const reader = body.getReader()
|
||||||
if (value.done) throw new Error("event stream closed")
|
const decoder = new TextDecoder()
|
||||||
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
|
let buffer = ""
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const boundary = buffer.match(/(?:\r\n|\r|\n){2}/)
|
||||||
|
if (!boundary || boundary.index === undefined) {
|
||||||
|
const value = await reader.read()
|
||||||
|
if (value.done) return
|
||||||
|
buffer += decoder.decode(value.value, { stream: true })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buffer.slice(0, boundary.index)
|
||||||
|
buffer = buffer.slice(boundary.index + boundary[0].length)
|
||||||
|
const data = record
|
||||||
|
.split(/\r\n|\r|\n/)
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice(5).replace(/^ /, ""))
|
||||||
|
if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n")))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await reader.cancel()
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
|
async function readEvent(reader: AsyncIterator<typeof Event.Type>) {
|
||||||
|
const value = await reader.next()
|
||||||
|
if (value.done) throw new Error("event stream closed")
|
||||||
|
return value.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEventType(reader: AsyncIterator<typeof Event.Type>, type: string) {
|
||||||
for (let index = 0; index < 20; index++) {
|
for (let index = 0; index < 20; index++) {
|
||||||
const event = await readEvent(reader)
|
const event = await readEvent(reader)
|
||||||
if (event.type === type) return event
|
if (event.type === type) return event
|
||||||
@@ -78,7 +109,7 @@ describe("v2 location HttpApi", () => {
|
|||||||
await using subscriber = await tmpdir({ git: true })
|
await using subscriber = await tmpdir({ git: true })
|
||||||
await using publisher = await tmpdir({ git: true })
|
await using publisher = await tmpdir({ git: true })
|
||||||
const response = await request("/api/event", subscriber.path)
|
const response = await request("/api/event", subscriber.path)
|
||||||
const reader = response.body!.getReader()
|
const reader = eventStream(response.body!)
|
||||||
const connected = await readEvent(reader)
|
const connected = await readEvent(reader)
|
||||||
expect(connected.type).toBe("server.connected")
|
expect(connected.type).toBe("server.connected")
|
||||||
expect(connected.location).toBeUndefined()
|
expect(connected.location).toBeUndefined()
|
||||||
@@ -90,6 +121,6 @@ describe("v2 location HttpApi", () => {
|
|||||||
location: { directory: publisher.path },
|
location: { directory: publisher.path },
|
||||||
data: { sessionID: expect.any(String) },
|
data: { sessionID: expect.any(String) },
|
||||||
})
|
})
|
||||||
await reader.cancel()
|
await reader.return(undefined)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
|||||||
import { Location } from "@opencode-ai/schema/location"
|
import { Location } from "@opencode-ai/schema/location"
|
||||||
import type { Definition } from "@opencode-ai/schema/event"
|
import type { Definition } from "@opencode-ai/schema/event"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
const fields = {
|
const fields = {
|
||||||
id: Event.ID,
|
id: Event.ID,
|
||||||
@@ -12,15 +12,9 @@ const fields = {
|
|||||||
location: Schema.optional(Location.Ref),
|
location: Schema.optional(Location.Ref),
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = (definitions: ReadonlyArray<Definition>) =>
|
const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
|
||||||
Schema.Union([
|
Schema.Union([
|
||||||
...definitions.map((definition) =>
|
...definitions,
|
||||||
Schema.Struct({
|
|
||||||
...fields,
|
|
||||||
type: Schema.Literal(definition.type),
|
|
||||||
data: definition.data,
|
|
||||||
}).annotate({ identifier: `V2Event.${definition.type}` }),
|
|
||||||
),
|
|
||||||
...(definitions.some((definition) => definition.type === "server.connected")
|
...(definitions.some((definition) => definition.type === "server.connected")
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
@@ -32,14 +26,14 @@ const schema = (definitions: ReadonlyArray<Definition>) =>
|
|||||||
]),
|
]),
|
||||||
]).annotate({ identifier: "V2Event" })
|
]).annotate({ identifier: "V2Event" })
|
||||||
|
|
||||||
const make = (definitions: ReadonlyArray<Definition>) => {
|
const make = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) => {
|
||||||
const EventSchema = schema(definitions)
|
const EventSchema = schema(definitions)
|
||||||
return {
|
return {
|
||||||
schema: EventSchema,
|
schema: EventSchema,
|
||||||
group: HttpApiGroup.make("server.event")
|
group: HttpApiGroup.make("server.event")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("event.subscribe", "/api/event", {
|
HttpApiEndpoint.get("event.subscribe", "/api/event", {
|
||||||
success: EventSchema,
|
success: HttpApiSchema.StreamSse({ data: EventSchema }),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "v2.event.subscribe",
|
identifier: "v2.event.subscribe",
|
||||||
@@ -52,8 +46,11 @@ const make = (definitions: ReadonlyArray<Definition>) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeEventGroup = (definitions: ReadonlyArray<Definition>) => make(definitions).group
|
export const makeEventGroup = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
|
||||||
|
make(definitions).group
|
||||||
|
|
||||||
const event = make(EventManifest.ServerDefinitions)
|
const event = make(EventManifest.ServerDefinitions)
|
||||||
export const EventGroup = event.group
|
export const EventGroup = event.group
|
||||||
export type Event = typeof event.schema.Type
|
export const OpenCodeEvent = event.schema
|
||||||
|
export type OpenCodeEvent = typeof OpenCodeEvent.Type
|
||||||
|
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ export {
|
|||||||
SessionInput,
|
SessionInput,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
} from "@opencode-ai/client/effect"
|
} from "@opencode-ai/client/effect"
|
||||||
|
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises"
|
|||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Effect, Option, Schema, Stream } from "effect"
|
import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
|
||||||
|
import type { OpenCodeEvent } from "../src"
|
||||||
|
|
||||||
test("embedded client uses the real router and handlers", async () => {
|
test("embedded client uses the real router and handlers", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
|
||||||
@@ -103,6 +104,88 @@ test("embedded client uses the real router and handlers", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Location-owned runner events reach the ready global client", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-"))
|
||||||
|
const database = Flag.OPENCODE_DB
|
||||||
|
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||||
|
const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src")
|
||||||
|
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const opencode = yield* OpenCode.create()
|
||||||
|
const connected = yield* Latch.make(false)
|
||||||
|
const prompted = yield* Deferred.make<OpenCodeEvent>()
|
||||||
|
yield* opencode.events.subscribe().pipe(
|
||||||
|
Stream.runForEach((event) =>
|
||||||
|
event.type === "server.connected"
|
||||||
|
? connected.open
|
||||||
|
: event.type === "session.next.prompted" && event.data.sessionID === sessionID
|
||||||
|
? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
|
||||||
|
: Effect.void,
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
yield* connected.await
|
||||||
|
yield* opencode.sessions.create({
|
||||||
|
id: sessionID,
|
||||||
|
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||||
|
})
|
||||||
|
yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) })
|
||||||
|
|
||||||
|
const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
|
||||||
|
expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) }))
|
||||||
|
})
|
||||||
|
await Effect.runPromise(Effect.scoped(program))
|
||||||
|
} finally {
|
||||||
|
Flag.OPENCODE_DB = database
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
test("independent embedded hosts do not share live notifications", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-"))
|
||||||
|
const database = Flag.OPENCODE_DB
|
||||||
|
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||||
|
const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src")
|
||||||
|
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const first = yield* OpenCode.create()
|
||||||
|
const second = yield* OpenCode.create()
|
||||||
|
const firstReady = yield* Latch.make(false)
|
||||||
|
const secondReady = yield* Latch.make(false)
|
||||||
|
const firstEvent = yield* Latch.make(false)
|
||||||
|
const secondEvent = yield* Latch.make(false)
|
||||||
|
const observe = (ready: Latch.Latch, event: Latch.Latch) =>
|
||||||
|
Stream.runForEach((notification: OpenCodeEvent) =>
|
||||||
|
notification.type === "server.connected"
|
||||||
|
? ready.open
|
||||||
|
: notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID
|
||||||
|
? event.open
|
||||||
|
: Effect.void,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
|
||||||
|
yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
|
||||||
|
yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
|
||||||
|
yield* first.sessions.create({
|
||||||
|
id: sessionID,
|
||||||
|
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||||
|
})
|
||||||
|
yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") })
|
||||||
|
|
||||||
|
yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
|
||||||
|
expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
|
||||||
|
})
|
||||||
|
await Effect.runPromise(Effect.scoped(program))
|
||||||
|
} finally {
|
||||||
|
Flag.OPENCODE_DB = database
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
test("embedded client is available as a Layer service", async () => {
|
test("embedded client is available as a Layer service", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
|
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
|
||||||
const database = Flag.OPENCODE_DB
|
const database = Flag.OPENCODE_DB
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Effect, Stream } from "effect"
|
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import * as Sse from "effect/unstable/encoding/Sse"
|
import * as Sse from "effect/unstable/encoding/Sse"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
|
|
||||||
|
const subscriberCapacity = 256
|
||||||
|
|
||||||
function eventData(data: unknown): Sse.Event {
|
function eventData(data: unknown): Sse.Event {
|
||||||
return {
|
return {
|
||||||
_tag: "Event",
|
_tag: "Event",
|
||||||
event: "message",
|
event: "message",
|
||||||
id: undefined,
|
id: undefined,
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +27,16 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers)
|
|||||||
type: "server.connected",
|
type: "server.connected",
|
||||||
data: {},
|
data: {},
|
||||||
}
|
}
|
||||||
|
const output = Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Acquiring the bounded stream installs its listener before readiness is observable.
|
||||||
|
const live = yield* EventV2.allBounded(events, subscriberCapacity)
|
||||||
|
return Stream.make(connected).pipe(Stream.concat(live))
|
||||||
|
}),
|
||||||
|
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
||||||
|
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
|
||||||
return HttpServerResponse.stream(
|
return HttpServerResponse.stream(
|
||||||
Stream.make(connected).pipe(
|
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
|
||||||
Stream.concat(events.all()),
|
|
||||||
Stream.map(eventData),
|
|
||||||
Stream.pipeThroughChannel(Sse.encode()),
|
|
||||||
Stream.encodeText,
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
contentType: "text/event-stream",
|
contentType: "text/event-stream",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user