diff --git a/.changeset/quiet-items-mate.md b/.changeset/quiet-items-mate.md new file mode 100644 index 000000000..efe448188 --- /dev/null +++ b/.changeset/quiet-items-mate.md @@ -0,0 +1,5 @@ +--- +"@effect-app/infra": minor +--- + +feat: add routing2 based on @effect/rpc diff --git a/packages/infra/package.json b/packages/infra/package.json index eb1b6c86b..25cba6d91 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -8,6 +8,8 @@ "@azure/service-bus": "^7.9.5", "@effect-app/core": "workspace:*", "@effect-app/infra-adapters": "workspace:*", + "@effect/rpc": "^0.40.2", + "@effect/rpc-http": "^0.38.3", "effect-app": "workspace:*", "@effect-app/schema": "workspace:*", "express-oauth2-jwt-bearer": "^1.6.0", @@ -186,6 +188,26 @@ "default": "./_cjs/api/routing/schema/routing.cjs" } }, + "./api/routing2": { + "import": { + "types": "./dist/api/routing2.d.ts", + "default": "./dist/api/routing2.js" + }, + "require": { + "types": "./dist/api/routing2.d.ts", + "default": "./_cjs/api/routing2.cjs" + } + }, + "./api/routing2/DynamicMiddleware": { + "import": { + "types": "./dist/api/routing2/DynamicMiddleware.d.ts", + "default": "./dist/api/routing2/DynamicMiddleware.js" + }, + "require": { + "types": "./dist/api/routing2/DynamicMiddleware.d.ts", + "default": "./_cjs/api/routing2/DynamicMiddleware.cjs" + } + }, "./api/setupRequest": { "import": { "types": "./dist/api/setupRequest.d.ts", diff --git a/packages/infra/src/api/routing2.ts b/packages/infra/src/api/routing2.ts new file mode 100644 index 000000000..bd24acdda --- /dev/null +++ b/packages/infra/src/api/routing2.ts @@ -0,0 +1,338 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { EffectUnunified } from "@effect-app/core/Effect" +import { typedKeysOf } from "@effect-app/core/utils" +import type { Compute } from "@effect-app/core/utils" +import type { _E, _R } from "@effect-app/infra/api/routing" +import type { Rpc } from "@effect/rpc" +import { RpcRouter } from "@effect/rpc" +import { HttpRpcRouter } from "@effect/rpc-http" +import type { S } from "effect-app" +import { Effect, Predicate } from "effect-app" +import { HttpRouter } from "effect-app/http" +import type { GetEffectContext, Middleware } from "./routing2/DynamicMiddleware.js" +import { makeRpc } from "./routing2/DynamicMiddleware.js" + +export interface Hint { + Err: Err +} + +type HandleVoid = [Expected] extends [void] + ? [Actual] extends [void] ? Result : Hint<"You're returning non void for a void Response, please fix"> + : Result + +type AnyRequestModule = S.Schema.Any & { success?: S.Schema.Any; failure?: S.Schema.Any } + +type GetSuccess = T extends { success: S.Schema.Any } ? T["success"] : typeof S.Void + +type GetSuccessShape = RT extends "raw" + ? S.Schema.Encoded> + : S.Schema.Type> +type GetFailure = T["failure"] extends never ? typeof S.Never : T["failure"] + +export interface Handler { + new(): {} + _tag: RT + handler: ( + req: S.Schema.Type, + ctx: Context + ) => Effect< + A, + E, + R + > +} + +// Separate "raw" vs "d" to verify A (Encoded for "raw" vs Type for "d") +type AHandler = + | Handler< + Action, + "raw", + S.Schema.Encoded>, + S.Schema.Type>, + any, + { Response: any } + > + | Handler< + Action, + "d", + S.Schema.Type>, + S.Schema.Type>, + any, + { Response: any } + > + +type Filter = { + [K in keyof T as T[K] extends S.Schema.All & { success: S.Schema.Any; failure: S.Schema.Any } ? K : never]: T[K] +} + +export const makeRouter2 = >( + middleware: Middleware +) => { + const rpc = makeRpc(middleware) + function matchFor & { meta: { moduleName: string } }>( + rsc: Rsc + ) { + const meta = (rsc as any).meta as { moduleName: string } + if (!meta) throw new Error("Resource has no meta specified") // TODO: do something with moduleName+cur etc. + + type Filtered = Filter + const filtered = typedKeysOf(rsc).reduce((acc, cur) => { + if (Predicate.isObject(rsc[cur]) && rsc[cur]["success"]) { + acc[cur as keyof Filtered] = rsc[cur] + } + return acc + }, {} as Filtered) + + const matchWithServices = (action: Key) => { + return < + SVC extends Record< + string, + Effect + >, + R2, + E, + A + >( + _services: SVC, + f: ( + req: S.Schema.Type, + ctx: any + // ctx: Compute< + // LowerServices> & never // , + // "flat" + // > + ) => Effect + ) => + (req: any) => + // Effect.andThen(allLower(services), (svc2) => + // ...ctx, ...svc2, + f(req, { Response: rsc[action].success }) + } + + type MatchWithServicesNew = { + ( + f: Effect + ): HandleVoid< + GetSuccessShape, + A, + Handler< + Rsc[Key], + RT, + A, + E, + Exclude>, + { Response: Rsc[Key]["success"] } // + > + > + + ( + f: ( + req: S.Schema.Type, + ctx: { Response: Rsc[Key]["success"] } + ) => Effect + ): HandleVoid< + GetSuccessShape, + A, + Handler< + Rsc[Key], + RT, + A, + E, + Exclude>, + { Response: Rsc[Key]["success"] } // + > + > + + < + SVC extends Record< + string, + EffectUnunified + >, + R2, + E, + A + >( + services: SVC, + f: ( + req: S.Schema.Type, + ctx: Compute< + // LowerServices> & Pick, + { Response: Rsc[Key] }, + "flat" + > + ) => Effect + ): HandleVoid< + GetSuccessShape, + A, + Handler< + Rsc[Key], + RT, + A, + E, + Exclude>, + { Response: Rsc[Key]["success"] } // + > + > + } + + type Keys = keyof Filtered + + const controllers = < + THandlers extends { + // import to keep them separate via | for type checking!! + [K in Keys]: AHandler + } + >( + controllers: THandlers + ) => { + const handlers = typedKeysOf(filtered).reduce( + (acc, cur) => { + if (cur === "meta") return acc + ;(acc as any)[cur] = { + h: controllers[cur as keyof typeof controllers].handler, + Request: rsc[cur] + } + + return acc + }, + {} as { + [K in Keys]: { + h: ( + r: S.Schema.Type + ) => Effect< + S.Schema.Type>, + _E>, + _R> + > + Request: Rsc[K] + } + } + ) + + const mapped = typedKeysOf(handlers).reduce((acc, cur) => { + const handler = handlers[cur] + const req = handler.Request + + acc[cur] = rpc.effect(req, handler.h as any, meta.moduleName) // TODO + return acc + }, {} as any) as { + [K in Keys]: Rpc.Rpc< + Rsc[K], + _R> + > + } + + type RPCRouteR> = [T] extends [ + Rpc.Rpc + ] ? R + : never + + type RPCRouteReq> = [T] extends [ + Rpc.Rpc + ] ? Req + : never + + const router = RpcRouter.make(...Object.values(mapped) as any) as RpcRouter.RpcRouter< + RPCRouteReq, + RPCRouteR + > + + return HttpRouter.empty.pipe( + HttpRouter.all(("/rpc/" + rsc.meta.moduleName) as any, HttpRpcRouter.toHttpApp(router)) + ) + } + + const r = { + controllers, + ...typedKeysOf(filtered).reduce( + (prev, cur) => { + ;(prev as any)[cur] = (svcOrFnOrEffect: any, fnOrNone: any) => { + const stack = new Error().stack?.split("\n").slice(2).join("\n") + return Effect.isEffect(svcOrFnOrEffect) + ? class { + static stack = stack + static _tag = "d" + static handler = () => svcOrFnOrEffect + } + : typeof svcOrFnOrEffect === "function" + ? class { + static stack = stack + static _tag = "d" + static handler = (req: any, ctx: any) => svcOrFnOrEffect(req, { ...ctx, Response: rsc[cur].success }) + } + : class { + static stack = stack + static _tag = "d" + static handler = matchWithServices(cur)(svcOrFnOrEffect, fnOrNone) + } + } + ;(prev as any)[(cur as any) + "Raw"] = (svcOrFnOrEffect: any, fnOrNone: any) => { + const stack = new Error().stack?.split("\n").slice(2).join("\n") + return Effect.isEffect(svcOrFnOrEffect) + ? class { + static stack = stack + static _tag = "raw" + static handler = () => svcOrFnOrEffect + } + : typeof svcOrFnOrEffect === "function" + ? class { + static stack = stack + static _tag = "raw" + static handler = (req: any, ctx: any) => svcOrFnOrEffect(req, { ...ctx, Response: rsc[cur].success }) + } + : class { + static stack = stack + static _tag = "raw" + static handler = matchWithServices(cur)(svcOrFnOrEffect, fnOrNone) + } + } + return prev + }, + {} as + & { + // use Rsc 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 Filtered]: MatchWithServicesNew<"d", Key> + } + & { + // use Rsc as Key over using Keys, so that the Go To on X.Action remain in tact in Controllers files + /** + * Requires the Encoded shape (e.g directly undecoded from DB, so that we don't do multiple Decode/Encode) + */ + [Key in keyof Filtered as Key extends string ? `${Key}Raw` : never]: MatchWithServicesNew<"raw", Key> + } + ) + } + return r + } + + type RequestHandlersTest = { + [key: string]: HttpRouter.HttpRouter + } + function matchAll(handlers: T) { + const r = typedKeysOf(handlers).reduce((acc, cur) => { + return HttpRouter.concat(acc, handlers[cur] as any) + }, HttpRouter.empty) + + type _RRouter> = [T] extends [ + HttpRouter.HttpRouter + ] ? R + : never + + type _ERouter> = [T] extends [ + HttpRouter.HttpRouter + ] ? E + : never + + return r as HttpRouter.HttpRouter< + _ERouter, + _RRouter + > + } + + return { matchAll, matchFor } +} diff --git a/packages/infra/src/api/routing2/DynamicMiddleware.ts b/packages/infra/src/api/routing2/DynamicMiddleware.ts new file mode 100644 index 000000000..b7d6513c4 --- /dev/null +++ b/packages/infra/src/api/routing2/DynamicMiddleware.ts @@ -0,0 +1,211 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Rpc } from "@effect/rpc" +import type { Effect, Request } from "effect-app" +import { S } from "effect-app" +import type * as EffectRequest from "effect/Request" + +/** + * Middleware is inactivate by default, the Key is optional in route context, and the service is optionally provided as Effect Context. + * Unless configured as `true` + */ +export type ContextMap = [Key, Service, E, true] + +export type ContextMapCustom = [Key, Service, E, Custom] + +/** + * Middleware is active by default, and provides the Service at Key in route context, and the Service is provided as Effect Context. + * Unless omitted + */ +export type ContextMapInverted = [Key, Service, E, false] + +type Values> = T[keyof T] + +export type GetEffectContext, T> = Values< + // inverted + & { + [ + key in keyof CTXMap as CTXMap[key][3] extends true ? never + : key extends keyof T ? T[key] extends true ? never : CTXMap[key][0] + : CTXMap[key][0] + ]: // TODO: or as an Optional available? + CTXMap[key][1] + } + // normal + & { + [ + key in keyof CTXMap as CTXMap[key][3] extends false ? never + : key extends keyof T ? T[key] extends true ? CTXMap[key][0] : never + : never + ]: // TODO: or as an Optional available? + CTXMap[key][1] + } +> +export type ValuesOrNeverSchema> = Values extends never ? typeof S.Never : Values +export type GetEffectError, T> = Values< + // inverted + & { + [ + key in keyof CTXMap as CTXMap[key][3] extends true ? never + : key extends keyof T ? T[key] extends true ? never : CTXMap[key][0] + : CTXMap[key][0] + ]: // TODO: or as an Optional available? + CTXMap[key][2] + } + // normal + & { + [ + key in keyof CTXMap as CTXMap[key][3] extends false ? never + : key extends keyof T ? T[key] extends true ? CTXMap[key][0] : never + : never + ]: // TODO: or as an Optional available? + CTXMap[key][2] + } +> + +type GetFailure1 = F1 extends S.Schema.Any ? F1 : typeof S.Never +type GetFailure = F1 extends S.Schema.Any ? F2 extends S.Schema.Any ? S.Union<[F1, F2]> : F1 : F2 + +const merge = (a: any, b: Array) => + a !== undefined && b.length ? S.Union(a, ...b) : a !== undefined ? a : b.length ? S.Union(...b) : S.Never + +export const makeRpcClient = < + RequestConfig extends object, + CTXMap extends Record +>( + errors: { [K in keyof CTXMap]: CTXMap[K][2] } +) => { + // Long way around Context/C extends etc to support actual jsdoc from passed in RequestConfig etc... + type Context = { success: S.Schema.Any; failure: S.Schema.Any } + function TaggedRequest(): { + ( + tag: Tag, + fields: Payload, + config: RequestConfig & C + ): + & S.TaggedRequestClass< + Self, + Tag, + { readonly _tag: S.tag } & Payload, + typeof config["success"], + GetEffectError extends never ? typeof config["failure"] + : GetFailure> + > // typeof config["failure"] + & { config: Omit } + ( + tag: Tag, + fields: Payload, + config: RequestConfig & C + ): + & S.TaggedRequestClass< + Self, + Tag, + { readonly _tag: S.tag } & Payload, + typeof config["success"], + GetFailure1> + > + & { config: Omit } + ( + tag: Tag, + fields: Payload, + config: RequestConfig & C + ): + & S.TaggedRequestClass< + Self, + Tag, + { readonly _tag: S.tag } & Payload, + typeof S.Void, + GetFailure1> + > + & { config: Omit } + >( + tag: Tag, + fields: Payload, + config: C & RequestConfig + ): + & S.TaggedRequestClass< + Self, + Tag, + { readonly _tag: S.tag } & Payload, + typeof S.Void, + GetFailure1> + > + & { config: Omit } + ( + tag: Tag, + fields: Payload + ): S.TaggedRequestClass< + Self, + Tag, + { readonly _tag: S.tag } & Payload, + typeof S.Void, + typeof S.Never + > + } { + // TODO: filter errors based on config + take care of inversion + const errorSchemas = Object.values(errors) + return (( + tag: Tag, + fields: Fields, + config?: C + ) => { + const req = S.TaggedRequest()(tag, { + payload: fields, + failure: merge(config?.failure, errorSchemas), + success: config?.success ?? S.Void + }) + const req2 = Object.assign(req, { config }) + return req2 + }) as any + } + + return { + TaggedRequest + } +} + +export interface Middleware> { + contextMap: CTXMap + context: Context + execute: < + T extends { + config?: { [K in keyof CTXMap]?: any } + }, + Req extends S.TaggedRequest.All, + R + >( + schema: T & S.Schema, + handler: ( + request: Req + ) => Effect.Effect, EffectRequest.Request.Error, R>, + moduleName?: string + ) => ( + req: Req + ) => Effect.Effect< + Request.Request.Success, + Request.Request.Error, + any // smd + > +} + +export const makeRpc = >( + middleware: Middleware +) => { + return { + effect: ( + schema: T & S.Schema, + handler: ( + request: Req + ) => Effect.Effect< + EffectRequest.Request.Success, + EffectRequest.Request.Error, + R + >, + moduleName?: string + ) => { + return Rpc.effect>>( + schema, + middleware.execute(schema, handler, moduleName) + ) + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41588e50c..3e69185d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,6 +418,12 @@ importers: '@effect/platform': specifier: ^0.66.2 version: 0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4) + '@effect/rpc': + specifier: ^0.40.2 + version: 0.40.3(@effect/platform@0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4))(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4) + '@effect/rpc-http': + specifier: ^0.38.3 + version: 0.38.4(@effect/platform@0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4))(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4) '@effect/schema': specifier: ^0.74.1 version: 0.74.1(effect@3.8.4) @@ -1130,6 +1136,20 @@ packages: '@effect/schema': ^0.74.1 effect: ^3.8.4 + '@effect/rpc-http@0.38.4': + resolution: {integrity: sha512-P2MmQFMQTVF7ifcAH3aCWC6rFGR6pC1gYmWK4fFevG53vKonz8NSh+ZOAcdI20Us1tFye3xu0PpSdlN19kkI+w==} + peerDependencies: + '@effect/platform': ^0.66.3 + '@effect/schema': ^0.74.2 + effect: ^3.8.5 + + '@effect/rpc@0.40.3': + resolution: {integrity: sha512-keN35P9nYjrjSycIL3+2NwTTvfcKaXrDt9sjqNnJhCH018+Wo5MN5+TZhotURHzGb0SzTmIniktW3/q+DiuQ0g==} + peerDependencies: + '@effect/platform': ^0.66.3 + '@effect/schema': ^0.74.2 + effect: ^3.8.5 + '@effect/schema@0.74.1': resolution: {integrity: sha512-koTi0M1MYUjEDawLmksXBROROLx0BEG2ErwVGLVawmwuBwVbw1MTEd0QlG2+jds5avYXCJp67rHS7cuIxmnBwg==} peerDependencies: @@ -5793,6 +5813,19 @@ snapshots: find-my-way-ts: 0.1.5 multipasta: 0.2.5 + '@effect/rpc-http@0.38.4(@effect/platform@0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4))(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4)': + dependencies: + '@effect/platform': 0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4) + '@effect/rpc': 0.40.3(@effect/platform@0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4))(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4) + '@effect/schema': 0.74.1(effect@3.8.4) + effect: 3.8.4 + + '@effect/rpc@0.40.3(@effect/platform@0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4))(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4)': + dependencies: + '@effect/platform': 0.66.2(@effect/schema@0.74.1(effect@3.8.4))(effect@3.8.4) + '@effect/schema': 0.74.1(effect@3.8.4) + effect: 3.8.4 + '@effect/schema@0.74.1(effect@3.8.4)': dependencies: effect: 3.8.4 @@ -7883,7 +7916,7 @@ snapshots: debug: 4.3.7(supports-color@5.5.0) enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint-import-resolver-webpack@0.13.9)(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-webpack@0.13.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint-import-resolver-webpack@0.13.9(eslint-plugin-import@2.30.0)(webpack@5.77.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-bun-module: 1.1.0 @@ -7913,7 +7946,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.9.0(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint-import-resolver-webpack@0.13.9)(eslint@8.57.0): + eslint-module-utils@2.9.0(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-webpack@0.13.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint-import-resolver-webpack@0.13.9(eslint-plugin-import@2.30.0)(webpack@5.77.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -7954,7 +7987,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint-import-resolver-webpack@0.13.9)(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@8.57.0)(typescript@5.6.2(patch_hash=ecctaqko3b5tuqrrkmwqdwbtqa)))(eslint-import-resolver-webpack@0.13.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint-import-resolver-webpack@0.13.9(eslint-plugin-import@2.30.0)(webpack@5.77.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3