/** * Runs server-side handlers for RPC groups. * * This module connects typed handlers for an `RpcGroup` to a server `Protocol`. * It receives client messages, decodes request payloads, runs matching handlers * and middleware, tracks in-flight requests, handles acknowledgements and * interrupts, and sends responses back to clients. It also provides constructors * and layers for decoded messages, HTTP, WebSocket, sockets, stdio, and worker * runner protocols. * * @stability unstable * @since 4.0.0 */ import type { NonEmptyReadonlyArray } from "../Array.ts" import * as Bytes from "../Bytes.ts" import * as Cause from "../Cause.ts" import * as Context from "../Context.ts" import * as Deferred from "../Deferred.ts" import * as Effect from "../Effect.ts" import * as Exit from "../Exit.ts" import * as Fiber from "../Fiber.ts" import { constant, constTrue, constVoid, identity } from "../Function.ts" import * as Headers from "../http/Headers.ts" import * as HttpRouter from "../http/HttpRouter.ts" import * as HttpServerRequest from "../http/HttpServerRequest.ts" import * as HttpServerResponse from "../http/HttpServerResponse.ts" import { reportCauseUnsafe } from "../internal/effect.ts" import * as Latch from "../Latch.ts" import * as Layer from "../Layer.ts" import * as Option from "../Option.ts" import * as Predicate from "../Predicate.ts" import * as Pull from "../Pull.ts" import * as Queue from "../Queue.ts" import * as Schedule from "../Schedule.ts" import * as Schema from "../Schema.ts" import * as SchemaIssue from "../SchemaIssue.ts" import * as Scope from "../Scope.ts" import * as Semaphore from "../Semaphore.ts" import * as Socket from "../socket/Socket.ts" import * as SocketServer from "../socket/SocketServer.ts" import { Stdio } from "../Stdio.ts" import * as Stream from "../Stream.ts" import * as Tracer from "../Tracer.ts" import type * as Types from "../Types.ts" import * as Transferable from "../workers/Transferable.ts" import type { WorkerError } from "../workers/WorkerError.ts" import * as WorkerRunner from "../workers/WorkerRunner.ts" import * as RpcTracing from "./internal/tracing.ts" import * as Rpc from "./Rpc.ts" import type * as RpcGroup from "./RpcGroup.ts" import type { FromClient, FromClientEncoded, FromServer, FromServerEncoded, Request, RequestEncoded, ResponseExitEncoded } from "./RpcMessage.ts" import { constEof, constPong, RequestId, ResponseDefectEncoded } from "./RpcMessage.ts" import * as RpcSchema from "./RpcSchema.ts" import * as RpcSerialization from "./RpcSerialization.ts" import type { InitialMessage } from "./RpcWorker.ts" import { withRun } from "./Utils.ts" /** * The decoded RPC server boundary, accepting client messages for a client id * and allowing that client to be disconnected. * * @stability unstable * @category models * @since 4.0.0 */ export interface RpcServer { readonly write: (clientId: number, message: FromClient, options?: { readonly onRequest?: ((effect: Effect.Effect) => Effect.Effect) | undefined }) => Effect.Effect readonly disconnect: (clientId: number) => Effect.Effect } /** * Creates an RPC server for an already-decoded message channel, running * handlers for a group and sending decoded server responses through * `onFromServer`. * * **Details** * * If writing a stream `Chunk` fails with anything other than an interruption, * the request fails with that cause. `onFromServer` is assumed to have already * answered the client and reported it, so nothing else is sent or reported. * * @stability unstable * @category constructors * @since 4.0.0 */ export const makeNoSerialization: ( group: RpcGroup.RpcGroup, options: { readonly onFromServer: (response: FromServer) => Effect.Effect readonly disableTracing?: boolean | undefined readonly disableSpanPropagation?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableClientAcks?: boolean | undefined readonly concurrency?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined } ) => Effect.Effect< RpcServer, never, Rpc.ToHandler | Rpc.Middleware | Scope.Scope > = Effect.fnUntraced(function*( group: RpcGroup.RpcGroup, options: { readonly onFromServer: (response: FromServer) => Effect.Effect readonly disableTracing?: boolean | undefined readonly disableSpanPropagation?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableClientAcks?: boolean | undefined readonly concurrency?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined } ) { const enableTracing = options.disableTracing !== true const enableSpanPropagation = options.disableSpanPropagation !== true const supportsAck = options.disableClientAcks !== true const spanPrefix = options.spanPrefix const concurrency = options.concurrency ?? "unbounded" const disableFatalDefects = options.disableFatalDefects ?? false const services = yield* Effect.context | Scope.Scope>() const scope = Context.get(services, Scope.Scope) const trackFiber = Fiber.runIn(Scope.forkUnsafe(scope, "parallel")) const concurrencySemaphore = concurrency === "unbounded" ? undefined : yield* Semaphore.make(concurrency) type Client = { readonly id: number // lazily created: only streaming requests with client acks use latches latches: Map | undefined readonly fibers: Map> readonly serverClient: Rpc.ServerClient ended: boolean } const clients = new Map() let isShutdown = false const shutdownLatch = Latch.makeUnsafe(false) yield* Scope.addFinalizer( scope, Effect.withFiber((parent) => { isShutdown = true for (const client of clients.values()) { client.ended = true if (client.fibers.size === 0) { trackFiber(Effect.runForkWith(services)(endClient(client))) continue } for (const fiber of client.fibers.values()) { fiber.interruptUnsafe(parent.id) } } if (clients.size === 0) { return Effect.void } return shutdownLatch.await }) ) const disconnect = (clientId: number) => Effect.withFiber((parent) => { const client = clients.get(clientId) if (!client) return Effect.void for (const fiber of client.fibers.values()) { fiber.interruptUnsafe(parent.id) } clients.delete(clientId) return Effect.void }) const write: RpcServer["write"] = (clientId, message, opts) => Effect.catchDefect( Effect.withFiber((requestFiber) => { if (isShutdown) return Effect.interrupt let client = clients.get(clientId) if (!client) { client = { id: clientId, latches: undefined, fibers: new Map(), ended: false, serverClient: new Rpc.ServerClient(clientId) } clients.set(clientId, client) } else if ( client.ended && ((message._tag !== "Interrupt" && message._tag !== "Ack") || !client.fibers.has(message.requestId)) ) { return Effect.interrupt } switch (message._tag) { case "Request": { return handleRequest(requestFiber, client, message, opts) } case "Ack": { const latch = client.latches?.get(message.requestId) return latch ? Effect.asVoid(latch.open) : Effect.void } case "Interrupt": { const fiber = client.fibers.get(message.requestId) if (fiber) { fiber.interruptUnsafe(requestFiber.id, RpcSchema.ClientAbort.annotation) return Effect.void } return options.onFromServer({ _tag: "Exit", clientId, requestId: message.requestId, exit: Exit.interrupt() }) } case "Eof": { client.ended = true if (client.fibers.size > 0) return Effect.void return endClient(client) } default: { return sendDefect(client, `Unknown request tag: ${(message as any)._tag}`) } } }), (defect) => sendDefect(clients.get(clientId)!, defect) ) const endClient = (client: Client) => { clients.delete(client.id) const write = options.onFromServer({ _tag: "ClientEnd", clientId: client.id }) if (isShutdown && clients.size === 0) { return Effect.andThen(write, shutdownLatch.open) } return write } const handleRequest = ( requestFiber: Fiber.Fiber, client: Client, request: Request, opts: Parameters["write"]>[2] ): Effect.Effect => { if (client.fibers.has(request.id)) { return Effect.interrupt } const rpc = group.requests.get(request.tag) as any as Rpc.AnyWithProps const entry = Context.getOrUndefinedUnsafe(services, rpc?.key) as Rpc.Handler if (!rpc || !entry) { const write = Effect.catchDefect( options.onFromServer({ _tag: "Exit", clientId: client.id, requestId: request.id, exit: Exit.die(`Unknown request tag: ${request.tag}`) }), (defect) => sendDefect(client, defect) ) if (!client.ended || client.fibers.size > 0) return write return Effect.ensuring(write, endClient(client)) } const isStream = RpcSchema.isStreamSchema(rpc.successSchema) const metadata = { rpc, client: client.serverClient, requestId: request.id, headers: request.headers, payload: request.payload } const result = entry.handler(request.payload, metadata) // if the handler requested forking, then we skip the concurrency control const isWrapper = Rpc.isWrapper(result) const isFork = isWrapper && result.fork const isUninterruptible = isWrapper && result.uninterruptible // unwrap the fork data type const streamOrEffect = isWrapper ? result.value : result // set when a chunk could not be written; the writer has already responded let writeFailure: Cause.Cause | undefined const handler = isStream ? (streamEffect(client, request, streamOrEffect, (cause) => { writeFailure = cause }) as Effect.Effect<{} | Deferred.Deferred>) : (streamOrEffect as Effect.Effect<{} | Deferred.Deferred>) const withMiddleware = rpc.middlewares.size === 0 ? handler : isStream // middleware may rewrite or swallow a chunk write failure, so restore it ? Effect.flatMap( Effect.exit(applyMiddleware(services, handler, metadata)), (exit) => writeFailure ? Effect.failCause(writeFailure) : exit ) : applyMiddleware(services, handler, metadata) let responded = false const scope = Scope.makeUnsafe() let deferred: Deferred.Deferred | undefined = undefined let effect = Effect.onExit(withMiddleware, (exit) => { responded = true let write: Effect.Effect if (exit._tag === "Success") { if (Deferred.isDeferred(exit.value)) { deferred = exit.value write = Effect.void } else { write = options.onFromServer({ _tag: "Exit", clientId: client.id, requestId: request.id, exit: exit as any }) } } else if (writeFailure) { // onFromServer has already answered the client and reported the failure return Scope.closeUnsafe(scope, exit) ?? Effect.void } else if ( !disableFatalDefects && Cause.hasDies(exit.cause) && !Cause.hasInterrupts(exit.cause) ) { write = sendDefect(client, Cause.squash(exit.cause)) } else { write = options.onFromServer({ _tag: "Exit", clientId: client.id, requestId: request.id, exit: exit as any }) } const close = Scope.closeUnsafe(scope, exit) if (exit._tag === "Failure") { reportCauseUnsafe(Fiber.getCurrent()!, exit.cause) } return close ? Effect.ensuring(write, close) : write }) if (opts?.onRequest) { effect = opts.onRequest(effect) } if (enableTracing) { const parentSpan = Context.getOrUndefined( requestFiber.context, Tracer.ParentSpan ) effect = Effect.withSpan(effect, RpcTracing.spanName(spanPrefix, request.tag), { captureStackTrace: false, kind: "server", attributes: RpcTracing.spanAttributes(request.tag, options.spanAttributes), parent: enableSpanPropagation && request.spanId ? Tracer.externalSpan({ traceId: request.traceId!, spanId: request.spanId, sampled: request.sampled! }) : undefined, links: enableSpanPropagation && parentSpan ? [ { span: parentSpan, attributes: {} } ] : undefined }) } if (!isFork && concurrencySemaphore) { effect = concurrencySemaphore.withPermit(effect) } const runFork = Effect.runForkWith( Context.add(handlerContext(entry, requestFiber.context), Scope.Scope, scope) ) const fiber = trackFiber( runFork( // Register before the handler runs to catch synchronous cancellation. Effect.withFiber((fiber) => { client.fibers.set(request.id, fiber) return effect }), isUninterruptible ? { uninterruptible: true } : undefined ) ) fiber.addObserver(function onExit(exit: Exit.Exit): void { if (deferred) { const fiber = trackFiber(runFork(Effect.onExit(Deferred.await(deferred), (exit) => options.onFromServer({ _tag: "Exit", clientId: client.id, requestId: request.id, exit: exit as any })))) client.fibers.set(request.id, fiber) deferred = undefined fiber.addObserver(onExit) return } if (!responded && exit._tag === "Failure") { trackFiber( runFork( options.onFromServer({ _tag: "Exit", clientId: client.id, requestId: request.id, exit: Exit.interrupt() }) ) ) } client.fibers.delete(request.id) client.latches?.delete(request.id) if (client.ended && client.fibers.size === 0) { trackFiber(runFork(endClient(client))) } }) return Effect.void } const streamEffect = ( client: Client, request: Request, stream: | Stream.Stream | Effect.Effect, any, Scope.Scope>, onWriteFailure: (cause: Cause.Cause) => void ) => { const writeChunk = (values: NonEmptyReadonlyArray) => Effect.onError( options.onFromServer({ _tag: "Chunk", clientId: client.id, requestId: request.id, values }), (cause) => { if (!Cause.hasInterruptsOnly(cause)) onWriteFailure(cause) return Effect.void } ) let latch: Latch.Latch | undefined if (supportsAck) { client.latches ??= new Map() latch = client.latches.get(request.id) if (!latch) { latch = Latch.makeUnsafe(false) client.latches.set(request.id, latch) } } if (Effect.isEffect(stream)) { return stream.pipe( Effect.flatMap((queue) => Effect.whileLoop({ while: constTrue, body: constant( Effect.flatMap(Queue.takeAll(queue), (values) => { const write = writeChunk(values) if (!latch) return write latch.closeUnsafe() return Effect.flatMap(write, () => latch.await) }) ), step: constVoid }) ), Pull.catchDone(() => Effect.void), Effect.scoped ) } return Stream.runForEachArray(stream, (values) => { const write = writeChunk(values) if (!latch) return write latch.closeUnsafe() return Effect.andThen(write, latch.await) }) } const sendDefect = (client: Client, defect: unknown) => Effect.suspend(() => { const shouldEnd = client.ended && client.fibers.size === 0 const write = options.onFromServer({ _tag: "Defect", clientId: client.id, defect }) if (!shouldEnd) return write return Effect.andThen(write, endClient(client)) }) return identity>({ write, disconnect }) }) // Built-in JSON codecs depend only on the RPC schemas and can be shared. // Other factories may capture state at construction, so keep them per server. const isSharedCodecFor = (codecFor: RpcSerialization.CodecFor) => codecFor === RpcSerialization.json.codecFor interface RpcCodecs { readonly decode: (u: unknown) => Effect.Effect readonly encodeChunk: ( u: NonEmptyReadonlyArray ) => Effect.Effect, Schema.SchemaError> readonly encodeExit: (u: unknown) => Effect.Effect } const sharedRpcCodecs = new WeakMap() const rpcCodecs = (codecFor: RpcSerialization.CodecFor, rpc: Rpc.AnyWithProps): RpcCodecs => { const shared = isSharedCodecFor(codecFor) let codecs = shared ? sharedRpcCodecs.get(rpc) : undefined if (codecs === undefined) { const streamSchemas = RpcSchema.getStreamSchemas(rpc.successSchema) const parseOptions = Context.get(rpc.annotations, Rpc.ParseOptions) codecs = { decode: Schema.decodeUnknownEffect(codecFor(rpc.payloadSchema), parseOptions) as any, encodeChunk: Schema.encodeUnknownEffect( codecFor( Schema.NonEmptyArray(Option.isSome(streamSchemas) ? streamSchemas.value.success : Schema.Any) ), parseOptions ) as any, encodeExit: Schema.encodeUnknownEffect(codecFor(Rpc.exitSchema(rpc as any)), parseOptions) as any } if (shared) sharedRpcCodecs.set(rpc, codecs) } return codecs } interface DefectCodecs { readonly encodeDefectUnsafe: (defect: unknown) => unknown readonly encodeUnknownRequestExit: (u: unknown) => Effect.Effect } let sharedDefectCodecs: DefectCodecs | undefined const makeDefectCodecs = (codecFor: RpcSerialization.CodecFor): DefectCodecs => ({ encodeDefectUnsafe: Schema.encodeSync(codecFor(Schema.Defect())), // Unknown tags have no RPC schema, so use a defect-only exit schema. encodeUnknownRequestExit: Schema.encodeUnknownEffect( codecFor(Schema.Exit(Schema.Never, Schema.Never, Schema.Defect())) ) as any }) const defectCodecs = (codecFor: RpcSerialization.CodecFor): DefectCodecs => isSharedCodecFor(codecFor) ? (sharedDefectCodecs ??= makeDefectCodecs(codecFor)) : makeDefectCodecs(codecFor) // Merging the handler entry services with the request fiber services costs // O(services), so the merged context is cached per (entry, fiber context) // pair. Request fibers on persistent connections keep one context across // requests, leaving a single O(1) overlay per request for the request scope. const handlerContextCache = new WeakMap< Rpc.Handler, WeakMap, Context.Context> >() const handlerContext = ( entry: Rpc.Handler, fiberContext: Context.Context ): Context.Context => { let cache = handlerContextCache.get(entry) if (cache === undefined) { cache = new WeakMap() handlerContextCache.set(entry, cache) } let merged = cache.get(fiberContext) if (merged === undefined) { merged = Context.merge(entry.context, fiberContext) cache.set(fiberContext, merged) } return merged } const applyMiddleware = ( context: Context.Context, handler: Effect.Effect, options: { readonly rpc: Rpc.AnyWithProps readonly client: Rpc.ServerClient readonly requestId: RequestId readonly headers: Headers.Headers readonly payload: unknown } ) => { for (const service of options.rpc.middlewares) { const middleware = Context.getUnsafe(context, service) handler = middleware(handler as any, options) as any } return handler } /** * Runs an RPC server for a group using the current server `Protocol`, decoding * requests, invoking handlers, encoding responses, and managing in-flight * request lifetime. * * @stability unstable * @category running * @since 4.0.0 */ export const make: ( group: RpcGroup.RpcGroup, options?: | { readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly concurrency?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined } | undefined ) => Effect.Effect< never, never, | Protocol | Rpc.ToHandler | Rpc.Middleware | Rpc.ServicesServer > = Effect.fnUntraced(function*( group: RpcGroup.RpcGroup, options?: { readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly concurrency?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined } ) { const { codecFor, disconnects, end, run, send, supportsAck, supportsSpanPropagation, supportsTransferables } = yield* Protocol const { encodeDefectUnsafe, encodeUnknownRequestExit } = defectCodecs(codecFor) const services = yield* Effect.context | Rpc.Middleware>() const scope = yield* Scope.make() const server = yield* makeNoSerialization(group, { ...options, disableClientAcks: !supportsAck, disableSpanPropagation: !supportsSpanPropagation, onFromServer(response): Effect.Effect { const client = clients.get(response.clientId) if (!client) return Effect.void switch (response._tag) { case "Chunk": { const schemas = client.schemas.get(response.requestId) if (!schemas) return Effect.void return handleEncode( client, response.requestId, schemas, schemas.encodeChunk(response.values), "Chunk" ) } case "Exit": { const schemas = client.schemas.get(response.requestId) if (!schemas) return Effect.void client.schemas.delete(response.requestId) return handleEncode( client, response.requestId, schemas, schemas.encodeExit(response.exit), "Exit" ) } case "Defect": { return sendDefect(client, response.defect) } case "ClientEnd": { clients.delete(response.clientId) return end(response.clientId) } } } }).pipe(Scope.provide(scope)) // handle disconnects yield* Effect.forkChild( Effect.whileLoop({ while: constTrue, body: constant( Effect.flatMap(Queue.take(disconnects), (clientId) => { clients.delete(clientId) return server.disconnect(clientId) }) ), step: constVoid }) ) type Schemas = { readonly tag: string readonly decode: (u: unknown) => Effect.Effect, Schema.SchemaError> readonly encodeChunk: ( u: NonEmptyReadonlyArray ) => Effect.Effect, Schema.SchemaError> readonly encodeExit: (u: unknown) => Effect.Effect readonly context: Context.Context readonly collector?: Transferable.Collector["Service"] | undefined } const schemasCache = new WeakMap() const getSchemas = (rpc: Rpc.AnyWithProps) => { let schemas = schemasCache.get(rpc) if (!schemas) { const entry = Context.getOrUndefinedUnsafe(services, rpc.key) as Rpc.Handler schemas = { tag: rpc._tag, ...rpcCodecs(codecFor, rpc), context: entry.context } schemasCache.set(rpc, schemas) } return schemas } type Client = { readonly id: number readonly schemas: Map } const clients = new Map() const responseEnvelope = ( requestId: RequestId, tag: "Chunk" | "Exit", value: unknown ): FromServerEncoded => tag === "Chunk" ? { _tag: "Chunk", requestId, values: value as NonEmptyReadonlyArray } : { _tag: "Exit", requestId, exit: value as ResponseExitEncoded["exit"] } const handleEncode = ( client: Client, requestId: RequestId, schemas: Schemas, effect: Effect.Effect, tag: "Chunk" | "Exit" ) => { const collector = schemas.collector let encodeFailed = false // The schema encoders evaluate eagerly, so an encode that needs no // services is already a resolved success and can skip the effect wrappers. const write = Exit.isExit(effect) && Exit.isSuccess(effect) ? send(client.id, responseEnvelope(requestId, tag, effect.value), collector && collector.clearUnsafe()) : Effect.flatMap( Effect.catch( Effect.provideContext( collector ? Effect.provideService(effect, Transferable.Collector, collector) : effect, schemas.context ), (error) => Effect.withFiber((fiber) => { const defect = Cause.die( `Failed to encode response for RPC "${schemas.tag}": ${SchemaIssue.defaultFormatter(error.issue)}` ) reportCauseUnsafe(fiber, defect) encodeFailed = true return Effect.failCause(defect) }) ), (value) => send(client.id, responseEnvelope(requestId, tag, value), collector && collector.clearUnsafe()) ) return Effect.catchCause(write, (cause) => { client.schemas.delete(requestId) // An encode failure ends the request with the defect, so the request // span records it. Other failures stop the request as a cancellation. return Effect.andThen( sendRequestDefect(client, requestId, schemas.encodeExit, Cause.squash(cause)), encodeFailed ? Effect.failCause(cause) : server.write(client.id, { _tag: "Interrupt", requestId, interruptors: [] }) ) }) } const sendRequestDefect = ( client: Client, requestId: RequestId, encodeExit: Schemas["encodeExit"], defect: unknown ) => Effect.catchCause( Effect.flatMap( encodeExit(Exit.die(defect)), (exit) => send(client.id, responseEnvelope(requestId, "Exit", exit)) ), (cause) => sendDefect(client, Cause.squash(cause)) ) const sendDefect = (client: Client, defect: unknown) => Effect.catchCause( send(client.id, ResponseDefectEncoded(encodeDefectUnsafe(defect))), (cause) => Effect.annotateLogs(Effect.logDebug(cause), { module: "RpcServer", method: "sendDefect" }) ) const writeDecodedRequest = ( client: Client, requestId: RequestId, schemas: Schemas, request: RequestEncoded, payload: unknown ) => { client.schemas.set( requestId, supportsTransferables ? { ...schemas, collector: Transferable.makeCollectorUnsafe() } : schemas ) // the envelope is owned by this protocol loop, so it is decoded in place // instead of copied for every request const decoded = request as Types.Mutable decoded.id = requestId decoded.payload = payload decoded.headers = Headers.fromInput(request.headers) as any return server.write(client.id, decoded as any) } // main server loop return yield* run((clientId, request) => { let client = clients.get(clientId) if (!client) { client = { id: clientId, schemas: new Map() } clients.set(clientId, client) } switch (request._tag) { case "Request": { const tag = Object.hasOwn(request, "tag") ? (request.tag as string) : "" let requestId: RequestId switch (typeof request.id) { case "number": case "string": { requestId = RequestId(request.id) break } default: { return sendDefect(client, `Invalid request id: ${request.id}`) } } const rpc = group.requests.get(tag) if (!rpc) { return sendRequestDefect(client, requestId, encodeUnknownRequestExit, `Unknown request tag: ${tag}`) } const schemas = getSchemas(rpc as any) const decoded = schemas.decode(request.payload) // The schema decoders evaluate eagerly, so a decode that needs no // services is already a resolved success and can skip the effect // wrappers. if (Exit.isExit(decoded) && Exit.isSuccess(decoded)) { return writeDecodedRequest(client, requestId, schemas, request, decoded.value) } return Effect.matchEffect( Effect.provideContext(decoded, schemas.context), { onFailure: (error) => sendRequestDefect(client, requestId, schemas.encodeExit, SchemaIssue.defaultFormatter(error.issue)), onSuccess: (payload) => writeDecodedRequest(client, requestId, schemas, request, payload) } ) } case "Ping": { return Effect.catchCause(send(client.id, constPong), (cause) => sendDefect(client, Cause.squash(cause))) } case "Eof": { return server.write(clientId, request) } case "Ack": { // the branded `RequestId` shares the runtime representation of the // encoded id, so the message can be forwarded as-is return server.write(clientId, request as FromClient) } case "Interrupt": { return server.write(clientId, { ...request, requestId: RequestId(request.requestId), interruptors: [] }) } default: { return sendDefect(client, `Unknown request tag: ${(request as any)._tag}`) } } }).pipe( Effect.tapCause((cause) => Effect.logFatal("BUG: RpcServer protocol crashed", cause)), Effect.onExit((exit) => Scope.close(scope, exit)) ) }) /** * Provides a scoped layer that starts an RPC server for a group using the * current server `Protocol`. * * @stability unstable * @category layers * @since 4.0.0 */ export const layer = ( group: RpcGroup.RpcGroup, options?: { readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly concurrency?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined } ): Layer.Layer< never, never, | Protocol | Rpc.ToHandler | Rpc.Middleware | Rpc.ServicesServer > => Layer.effectDiscard(Effect.forkScoped(make(group, options))) /** * Creates a RPC server that registers a HTTP route with a `HttpRouter`. * * **Details** * * Defaults to using websockets for communication, but can be configured to use * HTTP. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerHttp = (options: { readonly group: RpcGroup.RpcGroup readonly path: HttpRouter.PathInput readonly protocol?: "http" | "websocket" | undefined readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly concurrency?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined readonly streamBufferSize?: number | "unbounded" | undefined }): Layer.Layer< never, never, | RpcSerialization.RpcSerialization | HttpRouter.HttpRouter | Rpc.ToHandler | Rpc.Middleware | Rpc.ServicesServer > => layer(options.group, options).pipe( Layer.provide( options.protocol === "http" ? layerProtocolHttp(options) : layerProtocolWebsocket(options) ) ) /** * Defines the service interface for an RPC server transport, responsible for receiving * encoded client messages, sending encoded responses, tracking clients, and * declaring transport capabilities. * * **When to use** * * Use to provide the transport boundary for RPC servers over HTTP, WebSocket, * workers, sockets, or custom protocols. * * @stability unstable * @category services * @since 4.0.0 */ export class Protocol extends Context.Service< Protocol, { readonly run: ( f: (clientId: number, data: FromClientEncoded) => Effect.Effect ) => Effect.Effect readonly disconnects: Queue.Dequeue readonly send: ( clientId: number, response: FromServerEncoded, transferables?: ReadonlyArray ) => Effect.Effect readonly end: (clientId: number) => Effect.Effect readonly clientIds: Effect.Effect> readonly initialMessage: Effect.Effect> readonly supportsAck: boolean readonly supportsTransferables: boolean readonly supportsSpanPropagation: boolean readonly supportsNotifications: boolean /** * Builds the codec that fills the `unknown` holes of the protocol messages, * re-passed from the `RpcSerialization` backing this transport. */ readonly codecFor: RpcSerialization.CodecFor } >()("effect/rpc/RpcServer/Protocol") { /** * Creates a server protocol service from the supplied RPC implementation. * * @stability unstable * @since 4.0.0 */ static make = withRun() } /** * Creates a server `Protocol` backed by the current `SocketServer`, accepting * socket connections and routing decoded RPC messages. * * @stability unstable * @category protocols * @since 4.0.0 */ export const makeProtocolSocketServer = Effect.gen(function*() { const server = yield* SocketServer.SocketServer const { onSocket, protocol } = yield* makeSocketProtocol yield* Effect.forkScoped( server.run((socket) => Effect.scoped(onSocket(socket))) ) return protocol }) /** * RPC protocol that uses `SocketServer` for communication. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerProtocolSocketServer: Layer.Layer< Protocol, never, RpcSerialization.RpcSerialization | SocketServer.SocketServer > = Layer.effect(Protocol)(makeProtocolSocketServer) /** * Creates a websocket server `Protocol` together with an HTTP effect that * upgrades the current request to a websocket and attaches it to the protocol. * * @stability unstable * @category protocols * @since 4.0.0 */ export const makeProtocolWithHttpEffectWebsocket: Effect.Effect< { readonly protocol: Protocol["Service"] readonly httpEffect: Effect.Effect< HttpServerResponse.HttpServerResponse, never, Scope.Scope | HttpServerRequest.HttpServerRequest > }, never, RpcSerialization.RpcSerialization > = Effect.gen(function*() { const { onSocket, protocol } = yield* makeSocketProtocol const httpEffect: Effect.Effect< HttpServerResponse.HttpServerResponse, never, Scope.Scope | HttpServerRequest.HttpServerRequest > = Effect.gen(function*() { const request = yield* HttpServerRequest.HttpServerRequest const socket = yield* Effect.orDie(request.upgrade) yield* onSocket(socket, Object.entries(request.headers)) return HttpServerResponse.empty() }) return { protocol, httpEffect } as const }) /** * Creates a websocket server `Protocol` and registers its upgrade handler as a * GET route on the current `HttpRouter`. * * @stability unstable * @category protocols * @since 4.0.0 */ export const makeProtocolWebsocket: (options: { readonly path: HttpRouter.PathInput }) => Effect.Effect< Protocol["Service"], never, RpcSerialization.RpcSerialization | HttpRouter.HttpRouter > = Effect.fnUntraced(function*(options) { const { httpEffect, protocol } = yield* makeProtocolWithHttpEffectWebsocket const router = yield* HttpRouter.HttpRouter yield* router.add("GET", options.path, httpEffect) return protocol }) /** * RPC protocol that uses WebSockets for communication. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerProtocolWebsocket = (options: { readonly path: HttpRouter.PathInput }): Layer.Layer< Protocol, never, RpcSerialization.RpcSerialization | HttpRouter.HttpRouter > => { return Layer.effect(Protocol)(makeProtocolWebsocket(options)) } /** * Creates an HTTP request/response server `Protocol` together with an HTTP * effect that decodes the current request and streams or returns encoded RPC * responses. * * @stability unstable * @category protocols * @since 4.0.0 */ export const makeProtocolWithHttpEffect: ( options?: { readonly streamBufferSize?: number | "unbounded" | undefined } | undefined ) => Effect.Effect< { readonly protocol: Protocol["Service"] readonly httpEffect: Effect.Effect< HttpServerResponse.HttpServerResponse, never, Scope.Scope | HttpServerRequest.HttpServerRequest > }, never, RpcSerialization.RpcSerialization > = Effect.fnUntraced(function*(options = {}) { const serialization = yield* RpcSerialization.RpcSerialization const { encodeDefectUnsafe } = defectCodecs(serialization.codecFor) const includesFraming = serialization.includesFraming const isBinary = !serialization.contentType.includes("json") const disconnects = yield* Queue.make() let writeRequest!: ( clientId: number, message: FromClientEncoded ) => Effect.Effect let clientId = 0 type Client = { readonly write: (bytes: FromServerEncoded) => Effect.Effect readonly end: Effect.Effect } const clients = new Map() const clientIds = new Set() const encoder = new TextEncoder() const httpEffect: Effect.Effect< HttpServerResponse.HttpServerResponse, never, Scope.Scope | HttpServerRequest.HttpServerRequest > = Effect.gen(function*() { const fiber = Fiber.getCurrent()! const request = Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest) const scope = Context.getUnsafe(fiber.context, Scope.Scope) const requestHeaders = Object.entries(request.headers) const data = yield* Effect.orDie( isBinary ? Effect.map(request.arrayBuffer, (buf) => new Uint8Array(buf)) : request.text ) const id = clientId++ const queue = yield* Queue.make({ capacity: includesFraming && options.streamBufferSize !== "unbounded" ? options.streamBufferSize ?? 16 : undefined }) const parser = serialization.makeUnsafe() const requestIds: Array = [] const offer = (data: Uint8Array | string) => typeof data === "string" ? Queue.offer(queue, encoder.encode(data)) : Queue.offer(queue, data) const client: Client = { write: !includesFraming ? (response) => // buffered responses cannot carry notifications, so they are dropped response._tag === "Request" && response.isNotification === true ? Effect.void : Queue.offer(queue, response) : (response) => { try { const encoded = parser.encode(response) if (encoded === undefined) return Effect.void return offer(encoded) } catch (cause) { return offer(parser.encode(ResponseDefectEncoded(encodeDefectUnsafe(cause)))!) } }, end: Queue.end(queue) } yield* Scope.addFinalizerExit(scope, () => { clients.delete(id) clientIds.delete(id) Queue.offerUnsafe(disconnects, id) if (queue.state._tag === "Done") return Effect.void return Effect.andThen( Queue.shutdown(queue), Effect.forEach( requestIds, (requestId) => writeRequest(id, { _tag: "Interrupt", requestId }), { discard: true } ) ) }) clients.set(id, client) clientIds.add(id) // @effect-diagnostics-next-line tryCatchInEffectGen:off try { const decoded = parser.decode(data) as ReadonlyArray for (let i = 0; i < decoded.length; i++) { const message = decoded[i] if (message._tag === "Request") { requestIds.push(RequestId(message.id)) ;(message as Types.Mutable).headers = requestHeaders.concat(message.headers) } yield* writeRequest(id, message) } } catch (cause) { yield* client.write(ResponseDefectEncoded(encodeDefectUnsafe(cause))) } yield* writeRequest(id, constEof) // Allow request aborts during response waits without unmasking response construction. if (!includesFraming) { const responses = yield* Effect.interruptible(Queue.collect(queue)) // Notification-only batches encode without a response body. const encoded = parser.encode(responses) return HttpServerResponse.text(encoded === undefined ? "" : encoded as string, { contentType: serialization.contentType }) } const initialChunk = yield* Effect.interruptible(Pull.catchDone( Queue.takeAll(queue as Queue.Dequeue), () => Effect.succeed([]) )) if (queue.state._tag === "Done") { return HttpServerResponse.uint8Array(initialChunk.length === 1 ? initialChunk[0] : Bytes.concat(initialChunk), { contentType: serialization.contentType }) } return HttpServerResponse.stream( Stream.fromArray(initialChunk).pipe( Stream.concat( Stream.fromQueue(queue as Queue.Dequeue) ) ), { contentType: serialization.contentType } ) }) const protocol = yield* Protocol.make((writeRequest_) => { writeRequest = writeRequest_ return Effect.succeed({ disconnects, send(clientId, response) { const client = clients.get(clientId) if (!client) return Effect.void return client.write(response) }, end(clientId) { const client = clients.get(clientId) if (!client) return Effect.void return client.end }, clientIds: Effect.sync(() => clientIds), initialMessage: Effect.succeedNone, supportsAck: false, supportsTransferables: false, supportsSpanPropagation: false, supportsNotifications: includesFraming, codecFor: serialization.codecFor }) }) return { protocol, httpEffect } as const }) /** * Creates an HTTP server `Protocol` and registers its request handler as a POST * route on the current `HttpRouter`. * * @stability unstable * @category protocols * @since 4.0.0 */ export const makeProtocolHttp: (options: { readonly path: HttpRouter.PathInput readonly streamBufferSize?: number | "unbounded" | undefined }) => Effect.Effect< Protocol["Service"], never, RpcSerialization.RpcSerialization | HttpRouter.HttpRouter > = Effect.fnUntraced(function*(options) { const { httpEffect, protocol } = yield* makeProtocolWithHttpEffect(options) const router = yield* HttpRouter.HttpRouter yield* router.add("POST", options.path, httpEffect) return protocol }) /** * Provides a server `Protocol` that uses HTTP POST requests for RPC * communication. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerProtocolHttp = (options: { readonly path: HttpRouter.PathInput readonly streamBufferSize?: number | "unbounded" | undefined }): Layer.Layer => { return Layer.effect(Protocol)(makeProtocolHttp(options)) } /** * Starts an RPC server for a group and returns the HTTP request/response effect * that serves the non-websocket HTTP RPC protocol. * * @stability unstable * @category running * @since 4.0.0 */ export const toHttpEffect: ( group: RpcGroup.RpcGroup, options?: { readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableFatalDefects?: boolean | undefined readonly streamBufferSize?: number | "unbounded" | undefined } | undefined ) => Effect.Effect< Effect.Effect, never, | Scope.Scope | RpcSerialization.RpcSerialization | Rpc.ToHandler | Rpc.Middleware | Rpc.ServicesServer > = Effect.fnUntraced(function*( group: RpcGroup.RpcGroup, options?: { readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableFatalDefects?: boolean | undefined readonly streamBufferSize?: number | "unbounded" | undefined } ) { const { httpEffect, protocol } = yield* makeProtocolWithHttpEffect(options) yield* make(group, options).pipe( Effect.provideService(Protocol, protocol), Effect.forkScoped ) // @effect-diagnostics-next-line returnEffectInGen:off return httpEffect }) /** * Starts an RPC server for a group and returns the HTTP effect that upgrades * requests to the websocket RPC protocol. * * @stability unstable * @category running * @since 4.0.0 */ export const toHttpEffectWebsocket: ( group: RpcGroup.RpcGroup, options?: { readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableFatalDefects?: boolean | undefined } | undefined ) => Effect.Effect< Effect.Effect, never, | Scope.Scope | RpcSerialization.RpcSerialization | Rpc.ToHandler | Rpc.Middleware | Rpc.ServicesServer > = Effect.fnUntraced(function*( group: RpcGroup.RpcGroup, options?: { readonly disableTracing?: boolean | undefined readonly spanPrefix?: string | undefined readonly spanAttributes?: Record | undefined readonly disableFatalDefects?: boolean | undefined } ) { const { httpEffect, protocol } = yield* makeProtocolWithHttpEffectWebsocket yield* make(group, options).pipe( Effect.provideService(Protocol, protocol), Effect.forkScoped ) // @effect-diagnostics-next-line returnEffectInGen:off return httpEffect }) /** * Creates a server `Protocol` that reads RPC messages from `Stdio.stdin` and * writes encoded responses to `Stdio.stdout`. * * @stability unstable * @category protocols * @since 4.0.0 */ export const makeProtocolStdio = Effect.gen(function*() { const stdio = yield* Stdio const fiber = Fiber.getCurrent()! const serialization = yield* RpcSerialization.RpcSerialization return yield* Protocol.make(Effect.fnUntraced(function*(writeRequest) { const queue = yield* Queue.bounded(8) const parser = serialization.makeUnsafe() yield* stdio.stdin.pipe( Stream.runForEach((data) => { const decoded = parser.decode(data) as ReadonlyArray if (decoded.length === 0) return Effect.void let i = 0 return Effect.whileLoop({ while: () => i < decoded.length, body: () => writeRequest(0, decoded[i++]), step: constVoid }) }), Effect.sandbox, Effect.tapError(Effect.logError), Effect.retry(Schedule.spaced(500)), Effect.andThen(() => writeRequest(0, constEof)), Effect.forkScoped ) yield* Stream.fromQueue(queue).pipe( Stream.run(stdio.stdout()), Effect.retry(Schedule.spaced(500)), Effect.ensuring(Effect.forkDetach(Fiber.interrupt(fiber), { startImmediately: true })), Effect.forkScoped ) return { disconnects: yield* Queue.make(), send(_clientId, response) { const responseEncoded = parser.encode(response) if (responseEncoded === undefined) { return Effect.void } return Queue.offer(queue, responseEncoded) }, end(_clientId) { return Queue.end(queue) }, clientIds: Effect.succeed(new Set([0])), initialMessage: Effect.succeedNone, supportsAck: true, supportsTransferables: false, supportsSpanPropagation: true, supportsNotifications: true, codecFor: serialization.codecFor } })) }) /** * Provides a server `Protocol` that reads RPC messages from `Stdio.stdin` and * writes encoded responses to `Stdio.stdout`. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerProtocolStdio: Layer.Layer< Protocol, never, RpcSerialization.RpcSerialization | Stdio > = Layer.effect(Protocol, makeProtocolStdio) /** * Creates a server `Protocol` backed by `WorkerRunnerPlatform`, routing worker * messages to the RPC server and server responses back to workers. * * @stability unstable * @category protocols * @since 4.0.0 */ export const makeProtocolWorkerRunner: Effect.Effect< Protocol["Service"], WorkerError, WorkerRunner.WorkerRunnerPlatform | Scope.Scope > = Protocol.make(Effect.fnUntraced(function*(writeRequest) { const fiber = Fiber.getCurrent()! const runner = yield* WorkerRunner.WorkerRunnerPlatform const backing = yield* runner.start() const initialMessage = yield* Deferred.make() const clientIds = new Set() const disconnects = yield* Queue.make() yield* backing.run((clientId, message) => { clientIds.add(clientId) if (message._tag === "InitialMessage") { return Deferred.succeed(initialMessage, message.value) } return writeRequest(clientId, message) }).pipe( Effect.tapCause(Effect.logError), Effect.onExit(() => Effect.sync(() => { fiber.currentDispatcher.scheduleTask(() => fiber.interruptUnsafe(fiber.id), 0) }) ), Effect.forkScoped ) if (backing.disconnects) { yield* Queue.take(backing.disconnects).pipe( Effect.tap((clientId) => { clientIds.delete(clientId) return Queue.offer(disconnects, clientId) }), Effect.forever, Effect.forkScoped ) } return { disconnects, send: backing.send, end(_clientId) { return Effect.void }, clientIds: Effect.sync(() => clientIds), initialMessage: Effect.asSome(Deferred.await(initialMessage)), supportsAck: true, supportsTransferables: true, supportsSpanPropagation: true, supportsNotifications: true, // Worker protocols use structured clone, so they do not depend on // `RpcSerialization`. A binary worker protocol is a separate protocol. codecFor: Schema.toCodecJson as RpcSerialization.CodecFor } })) /** * Provides a server `Protocol` backed by the current `WorkerRunnerPlatform`. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerProtocolWorkerRunner: Layer.Layer< Protocol, WorkerError, WorkerRunner.WorkerRunnerPlatform > = Layer.effect(Protocol)(makeProtocolWorkerRunner) // internal const makeSocketProtocol: Effect.Effect< { readonly protocol: Protocol["Service"] readonly onSocket: ( socket: Socket.Socket, headers?: ReadonlyArray<[string, string]> ) => Effect.Effect }, never, RpcSerialization.RpcSerialization > = Effect.gen(function*() { const serialization = yield* RpcSerialization.RpcSerialization const { encodeDefectUnsafe } = defectCodecs(serialization.codecFor) const disconnects = yield* Queue.make() let clientId = 0 const clients = new Map Effect.Effect }>() const clientIds = new Set() let writeRequest!: (clientId: number, message: FromClientEncoded) => Effect.Effect const onSocket = Effect.fnUntraced(function*(socket: Socket.Socket, headers?: ReadonlyArray<[string, string]>) { const scope = yield* Effect.scope const parser = serialization.makeUnsafe() const id = clientId++ yield* Scope.addFinalizerExit(scope, () => { clients.delete(id) clientIds.delete(id) return Queue.offer(disconnects, id) }) const writer = yield* socket.writer const write = (response: FromServerEncoded) => { try { const encoded = parser.encode(response) if (encoded === undefined) { return Effect.void } return Effect.orDie(writer.write(encoded)) } catch (cause) { return Effect.orDie( writer.write(parser.encode(ResponseDefectEncoded(encodeDefectUnsafe(cause)))!) ) } } clients.set(id, { write }) clientIds.add(id) const processData = (data: Uint8Array | string): Effect.Effect => { try { const decoded = parser.decode(data) as ReadonlyArray if (decoded.length === 0) return Effect.void let i = 0 return Effect.whileLoop({ while: () => i < decoded.length, body() { const message = decoded[i++] if (message._tag === "Request" && headers) { ;(message as Types.Mutable).headers = headers.concat(message.headers) } return writeRequest(id, message) }, step: constVoid }) } catch (cause) { if (Predicate.isTagged(cause, "MaxBufferSizeExceeded")) { return writer.write(new Socket.CloseEvent(1009, String(cause))) } return writer.write(parser.encode(ResponseDefectEncoded(encodeDefectUnsafe(cause)))!) } } yield* Effect.gen(function*() { const { pull } = yield* socket.reader while (true) { const frames = yield* pull for (let i = 0; i < frames.length; i++) { yield* processData(frames[i]) } } }).pipe( Effect.catchReason("SocketError", "SocketCloseError", (_) => Effect.void), Effect.orDie ) }) const protocol = yield* Protocol.make((writeRequest_) => { writeRequest = writeRequest_ return Effect.succeed({ disconnects, send: (clientId, response) => { const client = clients.get(clientId) if (!client) return Effect.void return Effect.orDie(client.write(response)) }, end(_clientId) { return Effect.void }, clientIds: Effect.sync(() => clientIds), initialMessage: Effect.succeedNone, supportsAck: true, supportsTransferables: false, supportsSpanPropagation: true, supportsNotifications: true, codecFor: serialization.codecFor }) }) return { protocol, onSocket } as const })