/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-empty-object-type */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { NonEmptyReadonlyArray } from "effect-app/Array" import { getMeta } from "effect-app/client" import * as Config from "effect-app/Config" import * as Effect from "effect-app/Effect" import { type HttpHeaders } from "effect-app/http" import * as Layer from "effect-app/Layer" import { Invalidation } from "effect-app/rpc" import { type GetEffectContext, type GetEffectError, type RpcContextMap } from "effect-app/rpc/RpcContextMap" import * as S from "effect-app/Schema" import { type TypeTestId } from "effect-app/TypeTest" import { typedKeysOf, typedValuesOf } from "effect-app/utils" import { type Yieldable } from "effect/Effect" import * as Predicate from "effect/Predicate" import * as Ref from "effect/Ref" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" import { Rpc, RpcGroup, type RpcSerialization, RpcServer } from "effect/unstable/rpc" import { type LayerUtils } from "./layerUtils.js" import { RequestType as RequestTypeAnnotation } from "./routing/middleware.js" export * from "./routing/middleware.js" export const applyRequestTypeInterruptibility = ( requestType: "command" | "query", effect: Effect.Effect ) => requestType === "command" ? Rpc.uninterruptible(effect) : effect // it's the result of extending S.Req setting success, config // it's a schema plus some metadata export type AnyRequestModule = S.Top & { _tag: string // unique identifier for the request module type: "command" | "query" stream: boolean config: any // ? success: S.Top // validates the success response error: S.Top // validates the failure response } // builder pattern for adding actions to a router until all actions are added export interface AddAction = {}> { accum: Accum add>( a: A ): A extends Handler ? Exclude extends never ? & Accum & { [K in M["_tag"]]: A } : & AddAction< Exclude, & Accum & { [K in M["_tag"]]: A } > & Accum & { [K in M["_tag"]]: A } : never } // note: // "d" stands for decoded i.e. the Type // "raw" stands for encoded i.e. the Encoded namespace RequestTypes { export const DECODED = "d" as const export type DECODED = typeof DECODED export const RAW = "raw" as const export type RAW = typeof RAW } type RequestType = typeof RequestTypes[keyof typeof RequestTypes] type GetSuccess = T extends { success: S.Top } ? T["success"] : typeof S.Void type GetFailure = T["error"] extends never ? typeof S.Never : T["error"] type GetSuccessShape = { d: S.Schema.Type> raw: S.Codec.Encoded> }[RT] interface HandlerBase { new(): {} _tag: RT stack: string handler: ( req: S.Schema.Type, headers: HttpHeaders.Headers ) => Effect.Effect | Stream.Stream } export interface Handler extends HandlerBase< Action, RT, GetSuccessShape, S.Schema.Type> | S.SchemaError, R > {} type AnyHandler = Handler< Action, RequestType, any // R > // a Resource is typically the whole module with all the exported sh*t // this helper retrieves only the entities (classes) which are built by extending S.Req type FilterRequestModules = { [K in keyof T as T[K] extends AnyRequestModule ? K : never]: T[K] } type RpcRouteR< T extends [any, (req: any, headers: HttpHeaders.Headers) => Effect.Effect] > = T extends [ any, (...args: any[]) => Effect.Effect ] ? R : never type EffectMatch< Resource extends Record, RequestContextMap extends Record, RT extends RequestType, Key extends keyof Resource > = , R2 = never, E = never>( f: (req: S.Schema.Type) => Effect.Effect ) => Handler< Resource[Key], RT, Exclude< Exclude>, Scope.Scope > > type StreamMatch< Resource extends Record, RequestContextMap extends Record, RT extends RequestType, Key extends keyof Resource > = , R2 = never, E = never>( f: (req: S.Schema.Type) => Stream.Stream ) => Handler< Resource[Key], RT, Exclude< Exclude>, Scope.Scope > > // Stream resources only accept Stream / Effect handlers; non-stream resources // only accept Effect handlers. Discriminated by the request module's `stream` flag. type Match< Resource extends Record, RequestContextMap extends Record, RT extends RequestType, Key extends keyof Resource > = Resource[Key] extends { stream: true } ? StreamMatch : EffectMatch export type RouteMatcher< RequestContextMap extends Record, Resource extends Record > = { // use Resource as Key over using Keys, so that the Go To on X.Action remain in tact in Controllers files /** * Requires the Type shape */ [Key in keyof FilterRequestModules]: & Match & { success: Resource[Key]["success"] successRaw: S.Codec> error: Resource[Key]["error"] /** * Requires the Encoded shape (e.g directly undecoded from DB, so that we don't do multiple Decode/Encode) */ raw: Match } } export const skipOnProd = Effect .gen(function*() { const env = yield* Config.string("env") return env !== "prod" }) .pipe(Effect.orDie) // Type helpers to extract middleware information from a resource's request classes. type MiddlewareOf> = Exclude< { [K in keyof M]: M[K] extends { readonly middleware?: infer MW } ? NonNullable : never }[keyof M], never > type ProvidesOf = MW extends { readonly provides: infer P } ? P : never type RequestContextMapOf = MW extends { requestContextMap: infer RCM extends Record } ? RCM : Record type LayerNormalize = L extends Layer.Layer ? Layer.Layer : Layer.Layer type LayerSuccess = L extends Layer.Layer ? A : never /** * Middleware tags are typically passed to `makeRpcClient` as the class value, so * the captured `MW` is a constructor type. Layers carry the *instance* type as * their success channel. Bridge the two so the constraint compares like-with-like. * * Effect middleware classes declare `new(_: never): Shape` which the standard * `T extends abstract new (...args: any) => infer I` form sometimes fails to * narrow. Use the `prototype` member instead — it is always the instance type. */ type MWService = MW extends { readonly prototype: infer P } ? P : MW /** * Type-level guard: emits a structural mismatch on `Resource` when the middleware * service identifier extracted from the resource's request classes is not provided * by the layer passed to `makeRouter`. When `MW` is `never` (no middleware on the * resource) or already a subtype of the layer's success, this resolves to `unknown` * and intersects harmlessly with `Resource`. */ type EnsureMiddlewareProvided = [MW] extends [never] ? unknown : [MWService] extends [LayerSuccess] ? unknown : { readonly __middlewareNotProvidedByRouterLayer: { readonly expected: MWService readonly providedByLayer: LayerSuccess } } // Safe wrappers that check the constraint before calling GetEffectContext/GetEffectError. // These avoid TypeScript constraint errors when the RC map type is deferred (generic). type SafeGetEffectContext = RCM extends Record ? GetEffectContext : never type SafeGetEffectError = RCM extends Record ? GetEffectError : never export const makeRouter = = Layer.Layer>( middlewareLive?: Live ) => { type ResourceMWDefault = LayerNormalize /** * Create a Router for specified resource. * Middleware schema/tag is read from the request classes (stored via `makeRpcClient`). * The middleware **Live** layer is the one passed to `makeRouter`. * If `check` is provided, the router will only be created if the effect succeeds with true. */ function matchFor< const Resource extends Record, MW = MiddlewareOf >( rsc: Resource & EnsureMiddlewareProvided, options?: { check?: Effect.Effect } ) { // MW is a defaulted type parameter so TypeScript evaluates MiddlewareOf // eagerly at each call site, producing a concrete type instead of a deferred conditional. type ResourceRequestContextMap = RequestContextMapOf type ResourceContextProviderA = ProvidesOf type HandlerContext = | SafeGetEffectContext | ResourceContextProviderA type HandlerWithInputGen< Action extends AnyRequestModule, RT extends RequestType > = ( req: S.Schema.Type ) => Generator< Yieldable< any, any, S.Schema.Type> | S.SchemaError, // the actual implementation of the handler may just require the dynamic context provided by the middleware // and the per request context provided by the context provider HandlerContext >, GetSuccessShape, never > type HandlerWithInputEff< Action extends AnyRequestModule, RT extends RequestType > = ( req: S.Schema.Type ) => Effect.Effect< GetSuccessShape, S.Schema.Type> | S.SchemaError, // the actual implementation of the handler may just require the dynamic context provided by the middleware // and the per request context provided by the context provider HandlerContext > type HandlerWithInputStream< Action extends AnyRequestModule, RT extends RequestType > = ( req: S.Schema.Type ) => Stream.Stream< GetSuccessShape, S.Schema.Type> | S.SchemaError, HandlerContext > // Stream resources only accept `(req) => Stream`; non-stream only Effect / Generator. type Handlers = Action extends { stream: true } ? HandlerWithInputStream : HandlerWithInputGen | HandlerWithInputEff type HandlersDecoded = Handlers type HandlersRaw = Action extends { stream: true } ? { raw: HandlerWithInputStream } : | { raw: HandlerWithInputGen } | { raw: HandlerWithInputEff } type AnyHandlers = HandlersRaw | HandlersDecoded const meta = getMeta(rsc) type RequestModules = FilterRequestModules const requestModules = typedKeysOf(rsc).reduce((acc, cur) => { if (Predicate.isObjectKeyword(rsc[cur]) && rsc[cur]["success"]) { acc[cur as keyof RequestModules] = rsc[cur] } return acc }, {} as RequestModules) const routeMatcher = typedKeysOf(requestModules).reduce( (prev, cur) => { ;(prev as any)[cur] = Object.assign((handlerImpl: any) => { // handlerImpl is the actual handler implementation if (handlerImpl[Symbol.toStringTag] === "GeneratorFunction") handlerImpl = Effect.fnUntraced(handlerImpl) const stack = new Error().stack?.split("\n").slice(2).join("\n") // oxlint-disable-next-line typescript/no-extraneous-class return class { static request = rsc[cur] static stack = stack static _tag = RequestTypes.DECODED static handler = handlerImpl } }, { success: rsc[cur].success, successRaw: S.toEncoded(rsc[cur].success), error: rsc[cur].error, raw: // "Raw" variations are for when you don't want to decode just to encode it again on the response // e.g for direct projection from DB // but more importantly, to skip Effectful decoders, like to resolve relationships from the database or remote client. (handlerImpl: any) => { if (handlerImpl[Symbol.toStringTag] === "GeneratorFunction") handlerImpl = Effect.fnUntraced(handlerImpl) const stack = new Error().stack?.split("\n").slice(2).join("\n") // oxlint-disable-next-line typescript/no-extraneous-class return class { static request = rsc[cur] static stack = stack static _tag = RequestTypes.RAW static handler = handlerImpl } } }) return prev }, {} as RouteMatcher ) const router3: < const Impl extends { [K in keyof FilterRequestModules]: AnyHandlers } >( impl: Impl ) => { [K in keyof Impl & keyof FilterRequestModules]: Handler< FilterRequestModules[K], Impl[K] extends { raw: any } ? RequestTypes.RAW : RequestTypes.DECODED, Exclude< Exclude< // retrieves context R from the actual implementation of the handler Impl[K] extends { raw: any } ? Impl[K]["raw"] extends (...args: any[]) => Effect.Effect ? R : Impl[K]["raw"] extends (...args: any[]) => Stream.Stream ? R : Impl[K]["raw"] extends (...args: any[]) => Generator< Yieldable > ? R : never : Impl[K] extends (...args: any[]) => Effect.Effect ? R : Impl[K] extends (...args: any[]) => Stream.Stream ? R : Impl[K] extends (...args: any[]) => Generator< Yieldable > ? R : never, | SafeGetEffectContext | ResourceContextProviderA >, Scope.Scope > > } = (impl: Record) => typedKeysOf(impl).reduce((acc, cur) => { acc[cur] = "raw" in impl[cur] ? routeMatcher[cur].raw(impl[cur].raw) : routeMatcher[cur](impl[cur]) return acc }, {} as any) const makeRoutes = < MakeE, MakeR, THandlers extends { // important to keep them separate via | for type checking!! [K in keyof RequestModules]: AnyHandler }, MakeDependencies extends NonEmptyReadonlyArray | never[] >( dependencies: MakeDependencies, make: ( match: any ) => | Effect.Effect | Generator, THandlers> ) => { const dependenciesL = (dependencies ? Layer.mergeAll(...dependencies as any) : Layer.empty) as Layer.Layer< LayerUtils.GetLayersSuccess, LayerUtils.GetLayersError, LayerUtils.GetLayersContext > const layer = Effect .gen(function*() { const finalMake = ((make as any)[Symbol.toStringTag] === "GeneratorFunction" ? Effect.fnUntraced(make as any)(router3) as any : make(router3) as any) as Effect.Effect const controllers = yield* finalMake // Read the middleware from the resource's request classes at runtime const mw = meta.middleware as any // return make.pipe(Effect.map((c) => controllers(c, dependencies))) const mapped = typedKeysOf(requestModules).reduce((acc, cur) => { const handler = controllers[cur as keyof typeof controllers] const resource = rsc[cur] acc[cur] = [ handler._tag === RequestTypes.RAW ? class extends (resource as any) { static success = S.toEncoded(resource.success) } as any : resource, (payload: any, headers: any) => { const result: any = handler.handler(payload, headers) if (resource.stream) { // Wrap stream items as { _tag: "value", value } and append a final // { _tag: "done", metadata } chunk carrying accumulated invalidation keys. // V2: on failure, convert to { _tag: "error", error, metadata } chunk so // clients can invalidate queries even when the stream fails. const keysRef = Ref.makeUnsafe>([]) const invalidationSet = Invalidation.makeInvalidationSet(keysRef) return Stream.concat( (result as Stream.Stream).pipe( Stream.map((item: any) => ({ _tag: "value" as const, value: item })), Stream.provideService(Invalidation.InvalidationSet, invalidationSet), // V3: after each value chunk, drain accumulated keys and emit a "metadata" // chunk if any keys were collected since the last drain. This lets clients // invalidate queries mid-stream without waiting for the "done" chunk. Stream.flatMap((valueChunk: any) => Stream .fromEffect( Ref.getAndSet(keysRef, []).pipe( Effect.map((keys) => keys.length > 0 ? [ valueChunk, { _tag: "metadata" as const, metadata: { invalidateQueries: keys } } ] : [valueChunk] ) ) ) .pipe(Stream.flatMap(Stream.fromIterable)) ), // V2: catch stream failures and embed them in the stream as an error chunk Stream.catch((err: any) => Stream.fromEffect( Ref.get(keysRef).pipe( Effect.flatMap((keys) => Effect.fail({ _tag: "error" as const, error: err, metadata: { invalidateQueries: keys } }) ) ) ) ) ), Stream.fromEffect( Ref.get(keysRef).pipe( Effect.map((keys) => ({ _tag: "done" as const, metadata: { invalidateQueries: keys } })) ) ) ) } let effect = Effect .annotateCurrentSpan({ "rpc.system": "effect-app", "rpc.service": meta.moduleName, "rpc.method": resource._tag, "code.function.name": resource._tag, "code.namespace": meta.moduleName, "app.rpc.type": resource.type }) .pipe(Effect.andThen(result as Effect.Effect)) // Commands: provide a request-scoped `InvalidationSet` and wrap both // success (`CommandResponseWithMetaData`) and handler-thrown failure // (`CommandFailureWithMetaData`) so the client receives accumulated // invalidation keys on either path. Middleware-thrown errors bypass the // wrap (they fail the outer effect before reaching this `.catch`) and // flow raw on the Cause; client decodes them via the rpc's // `middlewares[*].error` failure-union channel. if (resource.type === "command") { const keysRef = Ref.makeUnsafe>([]) const invalidationSet = Invalidation.makeInvalidationSet(keysRef) effect = effect.pipe( Effect.provideService(Invalidation.InvalidationSet, invalidationSet), Effect.flatMap((value) => Ref.get(keysRef).pipe( Effect.map((keys) => ({ payload: value, metadata: { invalidateQueries: keys } }) as any) ) ), Effect.catch((err: any) => Ref.get(keysRef).pipe( Effect.flatMap((keys) => Effect.fail({ _tag: "CommandFailureWithMetaData" as const, error: err, metadata: { invalidateQueries: keys } }) ) ) ) ) } return applyRequestTypeInterruptibility(resource.type, effect) } ] as const return acc }, {} as any) as { [K in keyof RequestModules]: [ Resource[K], ( req: any, headers: HttpHeaders.Headers ) => Effect.Effect< Effect.Success>, | Effect.Error> | SafeGetEffectError, Exclude< Effect.Services>, ResourceContextProviderA | SafeGetEffectContext > > ] } const rpcs = RpcGroup .make( ...typedValuesOf(mapped).map(([resource]) => { const isStream = resource.stream const isCommand = resource.type === "command" return (isCommand ? isStream ? Invalidation.makeStreamRpc(resource._tag, { payload: resource, success: resource.success, error: resource.error, stream: true as const }) : Invalidation.makeCommandRpc(resource._tag, { payload: resource, success: resource.success, error: resource.error }) : Rpc.make(resource._tag, { payload: resource, success: resource.success, error: resource.error, stream: isStream })) .annotate(mw.requestContext, resource.config ?? {}) .annotate(RequestTypeAnnotation, resource.type) }) ) .prefix(`${meta.moduleName}.`) .middleware(mw) const rpc = rpcs .toLayer(Effect.gen(function*() { return typedValuesOf(mapped).reduce((acc, [resource, handler]) => { acc[`${meta.moduleName}.${resource._tag}`] = handler return acc }, {} as Record) as any // TODO })) as unknown as Layer.Layer< { [K in keyof RequestModules]: Rpc.Handler }, MakeE, RpcRouteR > return RpcServer .layerHttp({ group: rpcs, path: ("/rpc/" + meta.moduleName) as `/${typeof meta.moduleName}`, protocol: "http" }) .pipe(Layer.provide(rpc)) }) .pipe(Layer.unwrap) const routes = layer.pipe( Layer.provide([ dependenciesL, (middlewareLive ?? Layer.empty) as Layer.Layer ]) ) const check = options?.check return check ? Effect .gen(function*() { if (!(yield* check)) { yield* Effect.logWarning(`Skipping router for module ${meta.moduleName}`) return Layer.empty } return routes }) .pipe(Layer.unwrap) : routes } const effect: { // Multiple times duplicated the "good" overload, so that errors will only mention the last overload when failing < const Make extends { dependencies?: ReadonlyArray effect: (match: typeof router3) => Generator< Yieldable< any, any, any, any >, { [K in keyof FilterRequestModules]: AnyHandler } > /** @deprecated */ readonly ಠ_ಠ: never } >( make: Make ): & Layer.Layer< never, | MakeErrors | MakeDepsE | Layer.Error, | MakeDepsIn | Layer.Services | Exclude< MakeContext, MakeDepsOut > | RpcSerialization.RpcSerialization > & { // just for type testing purposes [TypeTestId]: Make } < const Make extends { dependencies?: ReadonlyArray // v4: generators yield Yieldable with asEffect() effect: (match: typeof router3) => Generator< Yieldable, { [K in keyof FilterRequestModules]: AnyHandler } > } >( make: Make ): & Layer.Layer< never, | MakeErrors | MakeDepsE | Layer.Error, | MakeDepsIn | Layer.Services | Exclude< MakeContext, MakeDepsOut > | RpcSerialization.RpcSerialization > & { // just for type testing purposes readonly [TypeTestId]: Make } } = ((make: { dependencies: any; effect: any }) => Object.assign(makeRoutes(make.dependencies, make.effect), { make })) as any return effect } function matchAll< T extends { [key: string]: Layer.Layer } >( handlers: T ) { const routers = typedValuesOf(handlers) return Layer.mergeAll(...routers as [any]) as unknown as Layer.Layer< never, Layer.Error, Layer.Services > } return { matchAll, Router: matchFor } } export type MakeDeps = Make extends { readonly dependencies: ReadonlyArray } ? Make["dependencies"][number] : never export type MakeErrors = /*Make extends { readonly effect: (_: any) => Effect.Effect } ? E : Make extends { readonly effect: (_: any) => Effect.Effect } ? never : */ // v4: generators yield Yieldable with asEffect() Make extends { readonly effect: (_: any) => Generator> } ? never : Make extends { readonly effect: (_: any) => Generator> } ? E : never export type MakeContext = /*Make extends { readonly effect: (_: any) => Effect.Effect } ? R : Make extends { readonly effect: (_: any) => Effect.Effect } ? never : */ // v4: generators yield Yieldable with asEffect() Make extends { readonly effect: (_: any) => Generator> } ? never : Make extends { readonly effect: (_: any) => Generator> } ? R : never export type MakeHandlers> = /*Make extends { readonly effect: (_: any) => Effect.Effect<{ [K in keyof Handlers]: AnyHandler }, any, any> } ? Effect.Success> : */ Make extends { readonly effect: (_: any) => Generator } ? S : never export type MakeDepsE = Layer.Error> export type MakeDepsIn = Layer.Services> export type MakeDepsOut = Layer.Success>