diff --git a/bun.lockb b/bun.lockb index 22706bc..9ff58ea 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 2545760..52f0e92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thilawyn/thilalib", - "version": "0.1.16", + "version": "0.1.17", "type": "module", "files": [ "./dist" @@ -8,68 +8,44 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, - "./Layers": { - "import": { - "types": "./dist/Layers/index.d.ts", - "default": "./dist/Layers/index.js" - }, - "require": { - "types": "./dist/Layers/index.d.cts", - "default": "./dist/Layers/index.cjs" - } + "./Express": { + "types": "./dist/Express/index.d.ts", + "default": "./dist/Express/index.js" }, - "./Schema": { - "import": { - "types": "./dist/Schema/index.d.ts", - "default": "./dist/Schema/index.js" - }, - "require": { - "types": "./dist/Schema/index.d.cts", - "default": "./dist/Schema/index.cjs" - } - }, - "./Schema/MobX": { - "import": { - "types": "./dist/Schema/MobX/index.d.ts", - "default": "./dist/Schema/MobX/index.js" - }, - "require": { - "types": "./dist/Schema/MobX/index.d.cts", - "default": "./dist/Schema/MobX/index.cjs" - } - }, - "./Schema/TanStackForm": { - "import": { - "types": "./dist/Schema/TanStackForm/index.d.ts", - "default": "./dist/Schema/TanStackForm/index.js" - }, - "require": { - "types": "./dist/Schema/TanStackForm/index.d.cts", - "default": "./dist/Schema/TanStackForm/index.cjs" - } + "./TRPC": { + "types": "./dist/TRPC/index.d.ts", + "default": "./dist/TRPC/index.js" }, "./Types": { - "import": { - "types": "./dist/Types/index.d.ts", - "default": "./dist/Types/index.js" - }, - "require": { - "types": "./dist/Types/index.d.cts", - "default": "./dist/Types/index.cjs" - } + "types": "./dist/Types/index.d.ts", + "default": "./dist/Types/index.js" + }, + "./Schema": { + "types": "./dist/Schema/index.d.ts", + "default": "./dist/Schema/index.js" + }, + "./Schema/MobX": { + "types": "./dist/Schema/MobX/index.d.ts", + "default": "./dist/Schema/MobX/index.js" + }, + "./Schema/TanStackForm": { + "types": "./dist/Schema/TanStackForm/index.d.ts", + "default": "./dist/Schema/TanStackForm/index.js" + }, + "./JSONWebToken": { + "types": "./dist/JSONWebToken.d.ts", + "default": "./dist/JSONWebToken.js" + }, + "./OpenAIClient": { + "types": "./dist/OpenAIClient.d.ts", + "default": "./dist/OpenAIClient.js" } }, "scripts": { - "build": "tsup", + "build": "tsc", "lint:tsc": "tsc --noEmit", "clean:cache": "rm -f tsconfig.tsbuildinfo", "clean:dist": "rm -rf dist", @@ -80,18 +56,23 @@ "type-fest": "^4.26.0" }, "devDependencies": { - "@effect/schema": "^0.72.0", - "@prisma/studio-server": "^0.502.0", - "@tanstack/form-core": "^0.30.0", + "@effect/schema": "^0.72.3", + "@tanstack/form-core": "^0.32.0", + "@trpc/server": "^10.45.2", + "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.6", - "bun-types": "^1.1.26", - "effect": "^3.7.0", + "@types/ws": "^8.5.12", + "bun-types": "^1.1.27", + "effect": "^3.7.2", + "express": "^4.19.2", "jsonwebtoken": "^9.0.2", - "mobx": "^6.13.1", + "mobx": "^6.13.2", "npm-check-updates": "^17.1.1", "npm-sort": "^0.0.4", + "openai": "^4.58.1", "tsup": "^8.2.4", "tsx": "^4.19.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "ws": "^8.18.0" } } diff --git a/src/Express/ExpressApp.ts b/src/Express/ExpressApp.ts new file mode 100644 index 0000000..af91481 --- /dev/null +++ b/src/Express/ExpressApp.ts @@ -0,0 +1,22 @@ +import { Config, Context, Effect, Layer } from "effect" +import type { Express } from "express" +import { ImportError } from "../ImportError" + + +export class ExpressApp extends Context.Tag("ExpressApp")() {} + + +const importExpress = Effect.tryPromise({ + try: () => import("express"), + catch: cause => new ImportError({ path: "express", cause }), +}) + +export const ExpressAppLive = ( + config: { + readonly trustProxy?: Config.Config + } = {} +) => Layer.effect(ExpressApp, Effect.gen(function*() { + const app = (yield* importExpress).default() + app.set("trust proxy", (yield* config.trustProxy ?? Config.succeed(undefined)) ?? false) + return app +})) diff --git a/src/Express/ExpressNodeHTTPServer.ts b/src/Express/ExpressNodeHTTPServer.ts new file mode 100644 index 0000000..25ff95f --- /dev/null +++ b/src/Express/ExpressNodeHTTPServer.ts @@ -0,0 +1,70 @@ +import { Config, Context, Effect, Layer, Match } from "effect" +import type { Server } from "node:http" +import type { AddressInfo } from "node:net" +import { ImportError } from "../ImportError" +import { ExpressApp } from "./ExpressApp" + + +export class ExpressNodeHTTPServer extends Context.Tag("ExpressNodeHTTPServer")() {} + + +const importNodeHTTP = Effect.tryPromise({ + try: () => import("node:http"), + catch: cause => new ImportError({ path: "node:http", cause }), +}) + +const serverListeningMessage = Match.type().pipe( + Match.when(Match.null, () => "HTTP server listening"), + Match.when(Match.string, v => `HTTP server listening on ${ v }`), + Match.orElse(v => `HTTP server listening on ${ v.address === "::" ? "*" : v.address }:${ v.port }`), +) + +export const ExpressNodeHTTPServerLive = ( + config: { + readonly backlog?: Config.Config + readonly exclusive?: Config.Config + readonly host?: Config.Config + readonly ipv6Only?: Config.Config + readonly path?: Config.Config + readonly port?: Config.Config + readonly readableAll?: Config.Config + readonly signal?: AbortSignal + readonly writableAll?: Config.Config + } = {} +) => Layer.effect(ExpressNodeHTTPServer, Effect.acquireRelease( + Effect.gen(function*() { + const app = yield* ExpressApp + const http = yield* importNodeHTTP + + const options = { + backlog: yield* config.backlog ?? Config.succeed(undefined), + exclusive: yield* config.exclusive ?? Config.succeed(undefined), + host: yield* config.host ?? Config.succeed(undefined), + ipv6Only: yield* config.ipv6Only ?? Config.succeed(undefined), + path: yield* config.path ?? Config.succeed(undefined), + port: yield* config.port ?? Config.succeed(undefined), + readableAll: yield* config.readableAll ?? Config.succeed(undefined), + signal: config.signal, + writableAll: yield* config.writableAll ?? Config.succeed(undefined), + } as const + + return yield* Effect.async(resume => { + const server = http.createServer(app).listen(options, + () => resume( + Effect.succeed(server).pipe( + Effect.tap(Effect.logInfo( + serverListeningMessage(server.address()) + )) + ) + ) + ) + }) + }), + + server => Effect.gen(function*() { + yield* Effect.logInfo("HTTP server is stopping. Waiting for existing connections to end...") + yield* Effect.async(resume => { + server.close(() => resume(Effect.logInfo("HTTP server closed"))) + }) + }), +)) diff --git a/src/Express/example.ts b/src/Express/example.ts new file mode 100644 index 0000000..11fd4f1 --- /dev/null +++ b/src/Express/example.ts @@ -0,0 +1,17 @@ +import { Effect, Layer } from "effect" +import { ExpressAppLive } from "./ExpressApp" +import { ExpressNodeHTTPServerLive } from "./ExpressNodeHTTPServer" + + +const AppLive = ExpressAppLive() +const HTTPServerLive = ExpressNodeHTTPServerLive() + +const ServerLive = Layer.empty.pipe( + Layer.provideMerge(HTTPServerLive), + Layer.provideMerge(AppLive), +) + +Layer.launch(ServerLive).pipe( + Effect.scoped, + Effect.runPromise, +) diff --git a/src/Express/index.ts b/src/Express/index.ts new file mode 100644 index 0000000..c301111 --- /dev/null +++ b/src/Express/index.ts @@ -0,0 +1,2 @@ +export * as ExpressApp from "./ExpressApp" +export * as ExpressNodeHTTPServer from "./ExpressNodeHTTPServer" diff --git a/src/ImportError.ts b/src/ImportError.ts new file mode 100644 index 0000000..04b654e --- /dev/null +++ b/src/ImportError.ts @@ -0,0 +1,11 @@ +import { Data } from "effect" + + +export class ImportError extends Data.TaggedError("ImportError")<{ + path: string + cause: unknown +}> { + toString(): string { + return `Could not import '${ this.path }'` + } +} diff --git a/src/Layers/JSONWebToken.ts b/src/JSONWebToken.ts similarity index 88% rename from src/Layers/JSONWebToken.ts rename to src/JSONWebToken.ts index 24bf772..401ee6f 100644 --- a/src/Layers/JSONWebToken.ts +++ b/src/JSONWebToken.ts @@ -1,8 +1,11 @@ import { Context, Effect, Layer } from "effect" import type * as JWT from "jsonwebtoken" +import { ImportError } from "./ImportError" -export class JSONWebToken extends Context.Tag("JSONWebToken")() {} + +export interface JSONWebTokenService { sign: ( payload: string | object | Buffer, secretOrPrivateKey: JWT.Secret, @@ -11,7 +14,7 @@ export class JSONWebToken extends Context.Tag("JSONWebToken"), + > verify: ( token: string, @@ -21,13 +24,13 @@ export class JSONWebToken extends Context.Tag("JSONWebToken"), -}>() {} + > +} const importJWT = Effect.tryPromise({ try: () => import("jsonwebtoken"), - catch: cause => new Error("Could not import 'jsonwebtoken'. Make sure it is installed.", { cause }), + catch: cause => new ImportError({ path: "jsonwebtoken", cause }), }) export const JSONWebTokenLive = Layer.effect(JSONWebToken, importJWT.pipe( diff --git a/src/Layers/index.ts b/src/Layers/index.ts deleted file mode 100644 index 77d7071..0000000 --- a/src/Layers/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * A wrapper around the jsonwebtoken library for Effect - * Requires `effect`, `jsonwebtoken` and `@types/jsonwebtoken` to be installed - */ -export * as JSONWebToken from "./JSONWebToken" diff --git a/src/OpenAIClient.ts b/src/OpenAIClient.ts new file mode 100644 index 0000000..f6c7867 --- /dev/null +++ b/src/OpenAIClient.ts @@ -0,0 +1,69 @@ +import { Config, Context, Effect, Layer } from "effect" +import type { OpenAI } from "openai" +import { ImportError } from "./ImportError" + + +export class OpenAIClient extends Context.Tag("OpenAIClient")() {} + +export class OpenAIClientService { + constructor( + readonly openai: Effect.Effect.Success, + readonly client: OpenAI, + ) {} + + try( + try_: ( + client: OpenAI, + signal: AbortSignal, + ) => Promise + ) { + return Effect.tryPromise({ + try: signal => try_(this.client, signal), + catch: e => e instanceof this.openai.OpenAIError + ? e + : new Error(`Unknown OpenAIClient error: ${ e }`), + }) + } +} + + +const importOpenAI = Effect.tryPromise({ + try: () => import("openai"), + catch: cause => new ImportError({ path: "openai", cause }), +}) + +export const OpenAIClientLive = ( + config: { + readonly apiKey: Config.Config + readonly organization?: Config.Config + readonly project?: Config.Config + readonly baseURL?: Config.Config + readonly timeout?: Config.Config + readonly maxRetries?: Config.Config + + readonly httpAgent?: any + readonly fetch?: any + readonly defaultHeaders?: { [x: string]: string } + readonly defaultQuery?: { [x: string]: string } + } +) => Layer.effect(OpenAIClient, Effect.gen(function*() { + const openai = yield* importOpenAI + + return new OpenAIClientService( + openai, + + new openai.OpenAI({ + apiKey: yield* config.apiKey, + organization: (yield* config.organization ?? Config.succeed(undefined)) ?? null, + project: (yield* config.project ?? Config.succeed(undefined)) ?? null, + baseURL: (yield* config.baseURL ?? Config.succeed(undefined)) ?? "https://api.openai.com/v1", + timeout: yield* config.timeout ?? Config.succeed(undefined), + maxRetries: yield* config.maxRetries ?? Config.succeed(undefined), + + httpAgent: config.httpAgent, + fetch: config.fetch, + defaultHeaders: config.defaultHeaders, + defaultQuery: config.defaultQuery, + }), + ) +})) diff --git a/src/Layers/PrismaStudioRoute.ts b/src/PrismaStudioRoute.ts similarity index 100% rename from src/Layers/PrismaStudioRoute.ts rename to src/PrismaStudioRoute.ts diff --git a/src/TRPC/TRPCBuilder.ts b/src/TRPC/TRPCBuilder.ts new file mode 100644 index 0000000..5edd653 --- /dev/null +++ b/src/TRPC/TRPCBuilder.ts @@ -0,0 +1,20 @@ +import { Context, Effect, Layer } from "effect" +import { type TRPCContext } from "./TRPCContext" +import { importTRPCServer } from "./importTRPCServer" + + +const createTRPC = () => importTRPCServer.pipe(Effect.map(({ initTRPC }) => + initTRPC.context>().create() +)) + +export const Identifier = "@thilalib/TRPC/TRPCBuilder" +export interface TRPCBuilder extends Context.Tag> {} +export interface TRPCBuilderService extends Effect.Effect.Success>> {} + + +export const make = () => { + const TRPCBuilder = Context.GenericTag>(Identifier) + const TRPCBuilderLive = Layer.effect(TRPCBuilder, createTRPC()) + + return { TRPCBuilder, TRPCBuilderLive } as const +} diff --git a/src/TRPC/TRPCContext.ts b/src/TRPC/TRPCContext.ts new file mode 100644 index 0000000..94089d8 --- /dev/null +++ b/src/TRPC/TRPCContext.ts @@ -0,0 +1,38 @@ +import type { TRPCError } from "@trpc/server" +import { Data, type Effect, type Runtime } from "effect" +import type { RuntimeFiber } from "effect/Fiber" +import type express from "express" +import type { IncomingMessage } from "node:http" +import type { WebSocket } from "ws" + + +export interface TRPCContext { + readonly runtime: Runtime.Runtime + + readonly run: ( + effect: Effect.Effect, + options?: { readonly signal?: AbortSignal }, + ) => Promise + + readonly fork: ( + effect: Effect.Effect, + options?: Runtime.RunForkOptions, + ) => RuntimeFiber + + readonly transaction: TRPCContextTransaction +} + + +export type TRPCContextTransaction = Data.TaggedEnum<{ + readonly Express: { + readonly req: express.Request + readonly res: express.Response + } + + readonly WebSocket: { + readonly req: IncomingMessage + readonly res: WebSocket + } +}> + +export const TRPCContextTransactionEnum = Data.taggedEnum() diff --git a/src/TRPC/TRPCContextCreator.ts b/src/TRPC/TRPCContextCreator.ts new file mode 100644 index 0000000..9087b1e --- /dev/null +++ b/src/TRPC/TRPCContextCreator.ts @@ -0,0 +1,57 @@ +import type { CreateExpressContextOptions } from "@trpc/server/adapters/express" +import type { CreateWSSContextFnOptions } from "@trpc/server/adapters/ws" +import { Context, Effect, Layer, Runtime } from "effect" +import { createTRCPErrorMapper } from "./createTRCPErrorMapper" +import { TRPCContextTransactionEnum, type TRPCContext, type TRPCContextTransaction } from "./TRPCContext" + + +export const Identifier = "@thilalib/TRPC/TRPCContextCreator" + +export interface TRPCContextCreator extends Context.Tag> {} + +export interface TRPCContextCreatorService { + readonly createContext: (transaction: TRPCContextTransaction) => TRPCContext + readonly createExpressContext: (context: CreateExpressContextOptions) => TRPCContext + readonly createWebSocketContext: (context: CreateWSSContextFnOptions) => TRPCContext +} + +export const TRPCUnknownContextCreator = Context.GenericTag>(Identifier) + + +export const make = () => { + const TRPCContextCreator = Context.GenericTag>(Identifier) + + const TRPCContextCreatorLive = Layer.effect(TRPCContextCreator, Effect.gen(function*() { + const runtime = yield* Effect.runtime() + const mapErrors = yield* createTRCPErrorMapper + + const run = ( + effect: Effect.Effect, + options?: { readonly signal?: AbortSignal }, + ) => Runtime.runPromise(runtime)( + effect.pipe(mapErrors), + options, + ) + + const fork = ( + effect: Effect.Effect, + options?: Runtime.RunForkOptions, + ) => Runtime.runFork(runtime)( + effect.pipe(mapErrors), + options, + ) + + const createContext = (transaction: TRPCContextTransaction) => ({ + runtime, + run, + fork, + transaction, + }) + const createExpressContext = (context: CreateExpressContextOptions) => createContext(TRPCContextTransactionEnum.Express(context)) + const createWebSocketContext = (context: CreateWSSContextFnOptions) => createContext(TRPCContextTransactionEnum.WebSocket(context)) + + return { createContext, createExpressContext, createWebSocketContext } + })) + + return { TRPCContextCreator, TRPCContextCreatorLive } as const +} diff --git a/src/TRPC/TRPCExpressRoute.ts b/src/TRPC/TRPCExpressRoute.ts new file mode 100644 index 0000000..22333dd --- /dev/null +++ b/src/TRPC/TRPCExpressRoute.ts @@ -0,0 +1,27 @@ +import { Config, Effect, Layer } from "effect" +import { ExpressApp } from "../Express" +import { ImportError } from "../ImportError" +import { TRPCUnknownContextCreator } from "./TRPCContextCreator" +import { TRPCAnyRouter } from "./TRPCRouter" + + +const importTRPCServerExpressAdapter = Effect.tryPromise({ + try: () => import("@trpc/server/adapters/express"), + catch: cause => new ImportError({ path: "@trpc/server/adapters/express", cause }), +}) + +export const TRPCExpressRouteLive = ( + config: { + readonly root: Config.Config + } +) => Layer.effectDiscard(Effect.gen(function*() { + const { createExpressMiddleware } = yield* importTRPCServerExpressAdapter + const app = yield* ExpressApp.ExpressApp + + app.use(yield* config.root, + createExpressMiddleware({ + router: yield* TRPCAnyRouter, + createContext: (yield* TRPCUnknownContextCreator).createExpressContext, + }) + ) +})) diff --git a/src/TRPC/TRPCRouter.ts b/src/TRPC/TRPCRouter.ts new file mode 100644 index 0000000..55226bb --- /dev/null +++ b/src/TRPC/TRPCRouter.ts @@ -0,0 +1,18 @@ +import type { AnyRouter } from "@trpc/server" +import { Context, Effect, Layer } from "effect" + + +export const Identifier = "@thilalib/TRPC/TRPCRouter" +export interface TRPCRouter extends Context.Tag {} + +export const TRPCAnyRouter = Context.GenericTag(Identifier) + + +export const make = ( + router: Effect.Effect +) => { + const TRPCRouter = Context.GenericTag(Identifier) + const TRPCRouterLive = Layer.effect(TRPCRouter, router) + + return { TRPCRouter, TRPCRouterLive } as const +} diff --git a/src/TRPC/TRPCWebSocketServer.ts b/src/TRPC/TRPCWebSocketServer.ts new file mode 100644 index 0000000..22238fe --- /dev/null +++ b/src/TRPC/TRPCWebSocketServer.ts @@ -0,0 +1,66 @@ +import type { applyWSSHandler } from "@trpc/server/adapters/ws" +import { Config, Context, Effect, Layer } from "effect" +import type ws from "ws" +import { ExpressNodeHTTPServer } from "../Express" +import { ImportError } from "../ImportError" +import { TRPCUnknownContextCreator } from "./TRPCContextCreator" +import { TRPCAnyRouter } from "./TRPCRouter" + + +export class TRPCWebSocketServer extends Context.Tag("@thilalib/TRPC/TRPCWebSocketServer")() {} + +export interface TRPCWebSocketServerService { + wss: ws.Server + handler: ReturnType +} + + +const importWS = Effect.tryPromise({ + try: () => import("ws"), + catch: cause => new ImportError({ path: "ws", cause }), +}) + +const importTRPCServerWSAdapter = Effect.tryPromise({ + try: () => import("@trpc/server/adapters/ws"), + catch: cause => new ImportError({ path: "@trpc/server/adapters/ws", cause }), +}) + +export const TRPCWebSocketServerLive = ( + config: { + readonly host: Config.Config + } +) => Layer.effect(TRPCWebSocketServer, Effect.gen(function*() { + const { WebSocketServer } = yield* importWS + const { applyWSSHandler } = yield* importTRPCServerWSAdapter + + const host = yield* config.host + + return yield* Effect.acquireRelease( + Effect.gen(function*() { + yield* Effect.logInfo(`WebSocket server started on ${ host }`) + + const wss = new WebSocketServer({ + server: yield* ExpressNodeHTTPServer.ExpressNodeHTTPServer, + host, + }) + + return { + wss, + handler: applyWSSHandler({ + wss, + router: yield* TRPCAnyRouter, + createContext: (yield* TRPCUnknownContextCreator).createWebSocketContext, + }), + } + }), + + ({ wss, handler }) => Effect.gen(function*() { + yield* Effect.logInfo(`WebSocket server on ${ host } is stopping. Waiting for existing connections to end...`) + + handler.broadcastReconnectNotification() + yield* Effect.async(resume => { + wss.close(() => resume(Effect.logInfo("WebSocket server closed"))) + }) + }), + ) +})) diff --git a/src/TRPC/createTRCPErrorMapper.ts b/src/TRPC/createTRCPErrorMapper.ts new file mode 100644 index 0000000..d59ac86 --- /dev/null +++ b/src/TRPC/createTRCPErrorMapper.ts @@ -0,0 +1,63 @@ +import { Effect, type Cause } from "effect" +import { importTRPCServer } from "./importTRPCServer" + + +export const createTRCPErrorMapper = importTRPCServer.pipe(Effect.map(({ TRPCError }) => + (effect: Effect.Effect) => Effect.sandbox(effect).pipe( + Effect.catchTags({ + Empty: cause => Effect.fail( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + cause: new TRPCErrorCause(cause), + }) + ), + + Fail: cause => Effect.fail( + cause.error instanceof TRPCError + ? cause.error + : new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + cause: new TRPCErrorCause(cause), + }) + ), + + Die: cause => Effect.fail( + cause.defect instanceof TRPCError + ? cause.defect + : new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + cause: new TRPCErrorCause(cause), + }) + ), + + Interrupt: cause => Effect.fail( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + cause: new TRPCErrorCause(cause), + }) + ), + + Sequential: cause => Effect.fail( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + cause: new TRPCErrorCause(cause), + }) + ), + + Parallel: cause => Effect.fail( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + cause: new TRPCErrorCause(cause), + }) + ), + }), + + Effect.tapError(Effect.logError), + ) +)) + +class TRPCErrorCause extends Error { + constructor(readonly cause: Cause.Cause) { + super() + } +} diff --git a/src/TRPC/example.ts b/src/TRPC/example.ts new file mode 100644 index 0000000..1a3bd14 --- /dev/null +++ b/src/TRPC/example.ts @@ -0,0 +1,51 @@ +import { Config, Effect, Layer } from "effect" +import * as TRPC from "." +import { Express, JSONWebToken } from ".." + + +// Context available to the router procedures +type Services = + | JSONWebToken.JSONWebToken + +const ServicesLive = Layer.empty.pipe( + Layer.provideMerge(JSONWebToken.JSONWebTokenLive) +) + + +const { TRPCContextCreator, TRPCContextCreatorLive } = TRPC.TRPCContextCreator.make() +const { TRPCBuilder, TRPCBuilderLive } = TRPC.TRPCBuilder.make() + + +const router = TRPCBuilder.pipe(Effect.map(t => t.router({ + ping: t.procedure.query(({ ctx }) => ctx.run( + Effect.succeed("pong") + )), +}))) +const { TRPCRouter, TRPCRouterLive } = TRPC.TRPCRouter.make(router) + + +const ServerLive = Layer.empty.pipe( + Layer.provideMerge(TRPC.TRPCExpressRoute.TRPCExpressRouteLive({ + root: Config.succeed("/rpc") + })), + Layer.provideMerge(TRPC.TRPCWebSocketServer.TRPCWebSocketServerLive({ + host: Config.succeed("/rpc") + })), + + Layer.provideMerge(TRPCRouterLive), + Layer.provideMerge(TRPCBuilderLive), + Layer.provideMerge(TRPCContextCreatorLive), + + Layer.provideMerge(Express.ExpressNodeHTTPServer.ExpressNodeHTTPServerLive({ + port: Config.succeed(3000) + })), + Layer.provideMerge(Express.ExpressApp.ExpressAppLive()) +) + +await Effect.gen(function*() { + return yield* Layer.launch(ServerLive) +}).pipe( + Effect.provide(ServicesLive), + Effect.scoped, + Effect.runPromise, +) diff --git a/src/TRPC/importTRPCServer.ts b/src/TRPC/importTRPCServer.ts new file mode 100644 index 0000000..43db62b --- /dev/null +++ b/src/TRPC/importTRPCServer.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import { ImportError } from "../ImportError" + + +export const importTRPCServer: Effect.Effect< + typeof import("@trpc/server"), + ImportError +> = Effect.tryPromise({ + try: () => import("@trpc/server"), + catch: cause => new ImportError({ path: "@trpc/server", cause }), +}) diff --git a/src/TRPC/index.ts b/src/TRPC/index.ts new file mode 100644 index 0000000..71403b6 --- /dev/null +++ b/src/TRPC/index.ts @@ -0,0 +1,7 @@ +export * from "./middlewares" +export * as TRPCBuilder from "./TRPCBuilder" +export * as TRPCContext from "./TRPCContext" +export * as TRPCContextCreator from "./TRPCContextCreator" +export * as TRPCExpressRoute from "./TRPCExpressRoute" +export * as TRPCRouter from "./TRPCRouter" +export * as TRPCWebSocketServer from "./TRPCWebSocketServer" diff --git a/src/TRPC/middlewares.ts b/src/TRPC/middlewares.ts new file mode 100644 index 0000000..fcd57e3 --- /dev/null +++ b/src/TRPC/middlewares.ts @@ -0,0 +1,44 @@ +import { Effect, Match } from "effect" +import type { TRPCContextTransaction } from "./TRPCContext" +import { importTRPCServer } from "./importTRPCServer" + + +export const ExpressOnly = importTRPCServer.pipe(Effect.map(({ + experimental_standaloneMiddleware, + TRPCError, +}) => experimental_standaloneMiddleware<{ + ctx: { readonly transaction: TRPCContextTransaction } +}>().create(opts => + Match.value(opts.ctx.transaction).pipe( + Match.tag("Express", transaction => + opts.next({ ctx: { transaction } }) + ), + + Match.orElse(() => { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only Express transport is supported by this procedure", + }) + }), + ) +))) + +export const WebSocketOnly = importTRPCServer.pipe(Effect.map(({ + experimental_standaloneMiddleware, + TRPCError, +}) => experimental_standaloneMiddleware<{ + ctx: { readonly transaction: TRPCContextTransaction } +}>().create(opts => + Match.value(opts.ctx.transaction).pipe( + Match.tag("WebSocket", transaction => + opts.next({ ctx: { transaction } }) + ), + + Match.orElse(() => { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only WebSocket transport is supported by this procedure", + }) + }), + ) +))) diff --git a/src/index.ts b/src/index.ts index 1129b8d..f729f79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ -export * as Layers from "./Layers" +export * as Express from "./Express" +export * from "./ImportError" +export * as JSONWebToken from "./JSONWebToken" +export * as OpenAIClient from "./OpenAIClient" export * as Schema from "./Schema" export * as Types from "./Types" diff --git a/tsconfig.json b/tsconfig.json index 238655f..e46d674 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,13 +6,13 @@ "module": "ESNext", "moduleDetection": "force", "jsx": "react-jsx", - "allowJs": true, + // "allowJs": true, // Bundler mode "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + // "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "noEmit": true, + // "noEmit": true, // Best practices "strict": true, @@ -22,6 +22,12 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "noPropertyAccessFromIndexSignature": false, + + // Build + "outDir": "./dist", + "declaration": true + }, + + "include": ["./src"] } diff --git a/tsup.config.ts b/tsup.config.ts index 3ea8c21..c96c727 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,13 +4,16 @@ import { defineConfig } from "tsup" export default defineConfig({ entry: [ "./src/index.ts", - "./src/Layers/index.ts", + "./src/Express/index.ts", + "./src/TRPC/index.ts", + "./src/Types/index.ts", "./src/Schema/index.ts", "./src/Schema/MobX/index.ts", "./src/Schema/TanStackForm/index.ts", - "./src/Types/index.ts", + "./src/JSONWebToken.ts", + "./src/OpenAIClient.ts", ], - format: ["esm", "cjs"], + format: ["esm"], skipNodeModulesBundle: true, dts: true, splitting: true,