/* 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>