Compare commits

..

7 Commits

Author SHA1 Message Date
Julien Valverdé
daa98762fa 0.1.21 (#22)
All checks were successful
Publish / publish (push) Successful in 14s
Lint / lint (push) Successful in 12s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #22
2024-09-13 00:24:35 +02:00
Julien Valverdé
6713bba164 0.1.20 (#21)
All checks were successful
Publish / publish (push) Successful in 12s
Lint / lint (push) Successful in 12s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #21
2024-09-09 04:05:14 +02:00
Julien Valverdé
704aa945f7 0.1.19 (#20)
All checks were successful
Publish / publish (push) Successful in 12s
Lint / lint (push) Successful in 11s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #20
2024-09-08 01:42:56 +02:00
Julien Valverdé
ebc5b45380 0.1.18 (#19)
All checks were successful
Publish / publish (push) Successful in 12s
Lint / lint (push) Successful in 11s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #19
2024-09-07 21:09:20 +02:00
Julien Valverdé
02d8e38f4d 0.1.17 (#18)
All checks were successful
Publish / publish (push) Successful in 13s
Lint / lint (push) Successful in 11s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #18
2024-09-07 20:56:30 +02:00
Julien Valverdé
07578a7ac7 0.1.16 (#17)
All checks were successful
Publish / publish (push) Successful in 16s
Lint / lint (push) Successful in 11s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #17
2024-09-04 01:02:54 +02:00
Julien Valverdé
24d4ab6e46 0.1.15 (#16)
All checks were successful
Publish / publish (push) Successful in 17s
Lint / lint (push) Successful in 12s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #16
2024-09-04 00:33:45 +02:00
34 changed files with 978 additions and 117 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@thilawyn/thilalib", "name": "@thilawyn/thilalib",
"version": "0.1.14", "version": "0.1.21",
"type": "module", "type": "module",
"files": [ "files": [
"./dist" "./dist"
@@ -8,78 +8,75 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"import": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
}, },
"require": { "./Express": {
"types": "./dist/index.d.cts", "types": "./dist/Express/index.d.ts",
"default": "./dist/index.cjs" "default": "./dist/Express/index.js"
}
}, },
"./Layers": { "./TRPC": {
"import": { "types": "./dist/TRPC/index.d.ts",
"types": "./dist/Layers/index.d.ts", "default": "./dist/TRPC/index.js"
"default": "./dist/Layers/index.js"
},
"require": {
"types": "./dist/Layers/index.d.cts",
"default": "./dist/Layers/index.cjs"
}
},
"./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"
}
}, },
"./Types": { "./Types": {
"import": {
"types": "./dist/Types/index.d.ts", "types": "./dist/Types/index.d.ts",
"default": "./dist/Types/index.js" "default": "./dist/Types/index.js"
}, },
"require": { "./Schema": {
"types": "./dist/Types/index.d.cts", "types": "./dist/Schema/index.d.ts",
"default": "./dist/Types/index.cjs" "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"
},
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
} }
}, },
"scripts": { "scripts": {
"build": "tsup", "build": "tsc",
"lint:tsc": "tsc --noEmit", "lint:tsc": "tsc --noEmit",
"clean:cache": "rm -f tsconfig.tsbuildinfo", "clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules" "clean:node": "rm -rf node_modules"
}, },
"dependencies": { "dependencies": {
"remeda": "^2.11.0", "remeda": "^2.12.0",
"type-fest": "^4.25.0" "type-fest": "^4.26.1"
}, },
"devDependencies": { "devDependencies": {
"@effect/schema": "^0.71.1", "@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", "@types/jsonwebtoken": "^9.0.6",
"bun-types": "^1.1.26", "@types/ws": "^8.5.12",
"effect": "^3.6.5", "bun-types": "^1.1.27",
"effect": "^3.7.2",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mobx": "^6.13.1", "mobx": "^6.13.2",
"npm-check-updates": "^17.1.0", "npm-check-updates": "^17.1.1",
"npm-sort": "^0.0.4", "npm-sort": "^0.0.4",
"openai": "^4.60.0",
"tsup": "^8.2.4", "tsup": "^8.2.4",
"tsx": "^4.18.0", "tsx": "^4.19.1",
"typescript": "^5.5.4" "typescript": "^5.6.2",
"ws": "^8.18.0"
} }
} }

22
src/Express/ExpressApp.ts Normal file
View File

@@ -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")<ExpressApp, Express>() {}
const importExpress = Effect.tryPromise({
try: () => import("express"),
catch: cause => new ImportError({ path: "express", cause }),
})
export const ExpressAppLive = (
config: {
readonly trustProxy?: Config.Config<boolean | undefined>
} = {}
) => Layer.effect(ExpressApp, Effect.gen(function*() {
const app = (yield* importExpress).default()
app.set("trust proxy", (yield* config.trustProxy ?? Config.succeed(undefined)) ?? false)
return app
}))

View File

@@ -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")<ExpressNodeHTTPServer, Server>() {}
const importNodeHTTP = Effect.tryPromise({
try: () => import("node:http"),
catch: cause => new ImportError({ path: "node:http", cause }),
})
const serverListeningMessage = Match.type<AddressInfo | string | null>().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<number | undefined>
readonly exclusive?: Config.Config<boolean | undefined>
readonly host?: Config.Config<string | undefined>
readonly ipv6Only?: Config.Config<boolean | undefined>
readonly path?: Config.Config<string | undefined>
readonly port?: Config.Config<number | undefined>
readonly readableAll?: Config.Config<boolean | undefined>
readonly signal?: AbortSignal
readonly writableAll?: Config.Config<boolean | undefined>
} = {}
) => 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<Server>(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")))
})
}),
))

17
src/Express/example.ts Normal file
View File

@@ -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,
)

2
src/Express/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * as ExpressApp from "./ExpressApp"
export * as ExpressNodeHTTPServer from "./ExpressNodeHTTPServer"

11
src/ImportError.ts Normal file
View File

@@ -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 }'`
}
}

56
src/JSONWebToken.ts Normal file
View File

@@ -0,0 +1,56 @@
import { Context, Effect, Layer } from "effect"
import type * as JWT from "jsonwebtoken"
import { ImportError } from "./ImportError"
export class JSONWebToken extends Context.Tag("JSONWebToken")<JSONWebToken, JSONWebTokenService>() {}
export interface JSONWebTokenService {
sign: (
payload: string | object | Buffer,
secretOrPrivateKey: JWT.Secret,
options: JWT.SignOptions,
) => Effect.Effect<
string,
Error,
never
>
verify: (
token: string,
secretOrPublicKey: JWT.Secret,
options: JWT.VerifyOptions,
) => Effect.Effect<
string | JWT.Jwt | JWT.JwtPayload,
JWT.VerifyErrors | Error,
never
>
}
const importJWT = Effect.tryPromise({
try: () => import("jsonwebtoken"),
catch: cause => new ImportError({ path: "jsonwebtoken", cause }),
})
export const JSONWebTokenLive = Layer.effect(JSONWebToken, importJWT.pipe(
Effect.map(jwt => JSONWebToken.of({
sign: (payload, secretOrPrivateKey, options) => Effect.async(resume =>
jwt.sign(payload, secretOrPrivateKey, options, (err, token) => {
resume(token
? Effect.succeed(token)
: Effect.fail(err || new Error("Unknown error"))
)
})
),
verify: (token, secretOrPublicKey, options) => Effect.async(resume =>
jwt.verify(token, secretOrPublicKey, options, (err, decoded) => {
resume(decoded
? Effect.succeed(decoded)
: Effect.fail(err || new Error("Unknown error"))
)
})
),
}))
))

View File

@@ -1,46 +0,0 @@
import { Context, Effect, Layer } from "effect"
import jwt from "jsonwebtoken"
export class JSONWebToken extends Context.Tag("JSONWebToken")<JSONWebToken, {
sign: (
payload: string | object | Buffer,
secretOrPrivateKey: jwt.Secret,
options: jwt.SignOptions,
) => Effect.Effect<
string,
Error,
never
>,
verify: (
token: string,
secretOrPublicKey: jwt.Secret,
options: jwt.VerifyOptions,
) => Effect.Effect<
string | jwt.Jwt | jwt.JwtPayload,
jwt.VerifyErrors | Error,
never
>,
}>() {}
export const JSONWebTokenLive = Layer.succeed(JSONWebToken, {
sign: (payload, secretOrPrivateKey, options) => Effect.async(resume =>
jwt.sign(payload, secretOrPrivateKey, options, (err, token) => {
resume(token
? Effect.succeed(token)
: Effect.fail(err || new Error("Unknown error"))
)
})
),
verify: (token, secretOrPublicKey, options) => Effect.async(resume =>
jwt.verify(token, secretOrPublicKey, options, (err, decoded) => {
resume(decoded
? Effect.succeed(decoded)
: Effect.fail(err || new Error("Unknown error"))
)
})
),
})

View File

@@ -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"

69
src/OpenAIClient.ts Normal file
View File

@@ -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")<OpenAIClient, OpenAIClientService>() {}
export class OpenAIClientService {
constructor(
readonly openai: Effect.Effect.Success<typeof importOpenAI>,
readonly client: OpenAI,
) {}
try<A>(
try_: (
client: OpenAI,
signal: AbortSignal,
) => Promise<A>
) {
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<string>
readonly organization?: Config.Config<string | undefined>
readonly project?: Config.Config<string | undefined>
readonly baseURL?: Config.Config<string | undefined>
readonly timeout?: Config.Config<number | undefined>
readonly maxRetries?: Config.Config<number | undefined>
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,
}),
)
}))

44
src/PrismaStudioRoute.ts Normal file
View File

@@ -0,0 +1,44 @@
// import { StudioServer } from "@prisma/studio-server"
// import { Config, Effect, Layer } from "effect"
// import proxy from "express-http-proxy"
// import { ExpressApp } from "../http/ExpressApp.service"
// import { PrismaClient } from "./PrismaClient.service"
// export const PrismaStudioRoute = ({
// httpRoot = Config.succeed("/studio"),
// httpPort = Config.succeed(5555),
// schemaPath = Config.succeed(""),
// schemaText = Config.succeed(""),
// }: {
// readonly httpRoot?: Config.Config<string>
// readonly httpPort?: Config.Config<number>
// readonly schemaPath?: Config.Config<string>
// readonly schemaText?: Config.Config<string>
// } = {}) =>
// Layer.effectDiscard(Effect.acquireRelease(
// Effect.gen(function*() {
// const prisma = yield* PrismaClient
// const app = yield* ExpressApp
// const port = yield* httpPort
// const server = new StudioServer({
// port,
// schemaPath: yield* schemaPath,
// schemaText: yield* schemaText,
// versions: { prisma: prisma.Prisma.prismaVersion.client },
// })
// app.use(yield* httpRoot, proxy(`http://localhost:${ port }`))
// yield* Effect.promise(() => server.start())
// return server
// }),
// server => Effect.sync(() => server.stop()),
// ))
// export const PrismaStudioRouteLive = Layer.empty
// export const PrismaStudioRouteDebug = PrismaStudioRoute()

View File

@@ -3,12 +3,12 @@ import { makeObservable, observable, type CreateObservableOptions } from "mobx"
import { mapValues } from "remeda" import { mapValues } from "remeda"
interface ObservableClassSelf { export interface ObservableClassSelf {
new(...args: any[]): Schema.Struct.Type<Schema.Struct.Fields> new(...args: any[]): Schema.Struct.Type<Schema.Struct.Fields>
readonly fields: { readonly [K in keyof Schema.Struct.Fields]: Schema.Struct.Fields[K] } readonly fields: { readonly [K in keyof Schema.Struct.Fields]: Schema.Struct.Fields[K] }
} }
interface ObservableClassOptions extends Omit<CreateObservableOptions, "proxy"> {} export interface ObservableClassOptions extends Omit<CreateObservableOptions, "proxy"> {}
export const ObservableClass = (options?: ObservableClassOptions) => export const ObservableClass = (options?: ObservableClassOptions) =>
<Self extends ObservableClassSelf>(self: Self) => <Self extends ObservableClassSelf>(self: Self) =>

View File

@@ -0,0 +1 @@
export * from "./makeSchemaFormValidator"

View File

@@ -0,0 +1,138 @@
import { ArrayFormatter, Schema } from "@effect/schema"
import type { ParseOptions } from "@effect/schema/AST"
import type { DeepKeys, DeepValue, FieldApi, FormApi, FormValidationError, ValidationError, Validator } from "@tanstack/form-core"
import { Array, Effect, Either, flow, Layer, ManagedRuntime, pipe } from "effect"
import { mapValues } from "remeda"
export const makeSchemaFormValidator = <A, I>(
schema: Schema.Schema<A, I, never>,
options?: ParseOptions,
) => {
const decodeEither = Schema.decodeEither(schema, options)
return <
TFormData extends I,
TFormValidator extends Validator<TFormData, unknown> | undefined,
>(props: {
value: TFormData
formApi: FormApi<TFormData, TFormValidator>
}): FormValidationError<TFormData> =>
decodeEither(props.value).pipe(result =>
Either.isLeft(result)
? {
form: "Please check the fields",
fields: issuesToFieldsRecord(ArrayFormatter.formatErrorSync(result.left), props.formApi) as any,
}
: null
)
}
export const makeSchemaAsyncFormValidator = <A, I>(
schema: Schema.Schema<A, I, never>,
options?: ParseOptions,
) => {
const runtime = ManagedRuntime.make(Layer.empty)
const decode = Schema.decode(schema, options)
return <
TFormData extends I,
TFormValidator extends Validator<TFormData, unknown> | undefined,
>(props: {
value: TFormData
formApi: FormApi<TFormData, TFormValidator>
signal: AbortSignal
}): Promise<FormValidationError<TFormData>> =>
decode(props.value).pipe(
Effect.matchEffect({
onSuccess: () => Effect.succeed(null),
onFailure: e => ArrayFormatter.formatError(e).pipe(
Effect.map(issues => ({
form: "Please check the fields",
fields: issuesToFieldsRecord(issues, props.formApi) as any,
}))
),
}),
prgm => runtime.runPromise(prgm, { signal: props.signal }),
)
}
/**
* TODO: handle nested array fields
*/
const issuesToFieldsRecord = <
TFormData,
TFormValidator extends Validator<TFormData, unknown> | undefined = undefined,
>(
issues: readonly ArrayFormatter.Issue[],
_formApi: FormApi<TFormData, TFormValidator>,
) => pipe(issues,
Array.groupBy(issue => issue.path.join(".")),
mapValues(flow(
Array.map(issue => issue.message),
Array.join("\n"),
)),
)
export const makeSchemaFieldValidator = <A, I>(
schema: Schema.Schema<A, I, never>,
options?: ParseOptions,
) => {
const decodeEither = Schema.decodeEither(schema, options)
return <
TParentData,
TName extends DeepKeys<TParentData>,
TFieldValidator extends Validator<DeepValue<TParentData, TName>, unknown> | undefined,
TFormValidator extends Validator<TParentData, unknown> | undefined,
TData extends I & DeepValue<TParentData, TName>,
>(props: {
value: TData
fieldApi: FieldApi<TParentData, TName, TFieldValidator, TFormValidator, TData>
}): ValidationError =>
decodeEither(props.value).pipe(result =>
Either.isLeft(result)
? ArrayFormatter.formatErrorSync(result.left)
.map(issue => issue.message)
.join("\n")
: null
)
}
export const makeSchemaAsyncFieldValidator = <A, I>(
schema: Schema.Schema<A, I, never>,
options?: ParseOptions,
) => {
const runtime = ManagedRuntime.make(Layer.empty)
const decode = Schema.decode(schema, options)
return <
TParentData,
TName extends DeepKeys<TParentData>,
TFieldValidator extends Validator<DeepValue<TParentData, TName>, unknown> | undefined,
TFormValidator extends Validator<TParentData, unknown> | undefined,
TData extends I & DeepValue<TParentData, TName>,
>(props: {
value: TData
fieldApi: FieldApi<TParentData, TName, TFieldValidator, TFormValidator, TData>
signal: AbortSignal
}): Promise<ValidationError> =>
decode(props.value).pipe(
Effect.matchEffect({
onSuccess: () => Effect.succeed(null),
onFailure: e => ArrayFormatter.formatError(e).pipe(
Effect.map(flow(
Array.map(issue => issue.message),
Array.join("\n"),
))
),
}),
prgm => runtime.runPromise(prgm, { signal: props.signal }),
)
}

12
src/Schema/email.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Schema } from "@effect/schema"
export const email = Schema.pattern(
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
{
identifier: "email",
title: "email",
message: () => "Not an email address",
},
)

View File

@@ -1,5 +1,6 @@
export * from "./Class" export * from "./Class"
export * from "./DateTime" export * from "./DateTime"
export * from "./email"
export * from "./encodedAsPrismaJsonValue" export * from "./encodedAsPrismaJsonValue"
export * from "./Kind" export * from "./Kind"
export * as MobX from "./MobX" export * as MobX from "./MobX"
@@ -7,4 +8,5 @@ export * from "./MutableClass"
export * from "./MutableTaggedClass" export * from "./MutableTaggedClass"
export * from "./Tag" export * from "./Tag"
export * from "./TaggedClass" export * from "./TaggedClass"
export * as TanStackForm from "./TanStackForm"
export * from "./toJsonifiable" export * from "./toJsonifiable"

20
src/TRPC/TRPCBuilder.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Context, Effect, Layer } from "effect"
import { type TRPCContext } from "./TRPCContext"
import { importTRPCServer } from "./importTRPCServer"
const createTRPC = <R>() => importTRPCServer.pipe(Effect.map(({ initTRPC }) =>
initTRPC.context<TRPCContext<R>>().create()
))
export const Identifier = "@thilalib/TRPC/TRPCBuilder"
export interface TRPCBuilder<R> extends Context.Tag<typeof Identifier, TRPCBuilderService<R>> {}
export interface TRPCBuilderService<R> extends Effect.Effect.Success<ReturnType<typeof createTRPC<R>>> {}
export const make = <R>() => {
const TRPCBuilder = Context.GenericTag<typeof Identifier, TRPCBuilderService<R>>(Identifier)
const TRPCBuilderLive = Layer.effect(TRPCBuilder, createTRPC())
return { TRPCBuilder, TRPCBuilderLive } as const
}

42
src/TRPC/TRPCContext.ts Normal file
View File

@@ -0,0 +1,42 @@
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<R> extends TRPCContextRuntime<R>, TRPCContextTransaction {}
export interface TRPCContextRuntime<R> {
readonly runtime: Runtime.Runtime<R>
readonly run: <A, E>(
effect: Effect.Effect<A, E, R>,
options?: { readonly signal?: AbortSignal },
) => Promise<A>
readonly fork: <A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunForkOptions,
) => RuntimeFiber<A, TRPCError>
}
export interface TRPCContextTransaction {
readonly transaction: TRPCTransaction
}
export type TRPCTransaction = Data.TaggedEnum<{
readonly Express: {
readonly req: express.Request
readonly res: express.Response
}
readonly WebSocket: {
readonly req: IncomingMessage
readonly res: WebSocket
}
}>
export const TRPCTransactionEnum = Data.taggedEnum<TRPCTransaction>()

View File

@@ -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 { TRPCTransactionEnum, type TRPCContext, type TRPCTransaction } from "./TRPCContext"
export const Identifier = "@thilalib/TRPC/TRPCContextCreator"
export interface TRPCContextCreator<R> extends Context.Tag<typeof Identifier, TRPCContextCreatorService<R>> {}
export interface TRPCContextCreatorService<R> {
readonly createContext: (transaction: TRPCTransaction) => TRPCContext<R>
readonly createExpressContext: (context: CreateExpressContextOptions) => TRPCContext<R>
readonly createWebSocketContext: (context: CreateWSSContextFnOptions) => TRPCContext<R>
}
export const TRPCUnknownContextCreator = Context.GenericTag<typeof Identifier, TRPCContextCreatorService<unknown>>(Identifier)
export const make = <R>() => {
const TRPCContextCreator = Context.GenericTag<typeof Identifier, TRPCContextCreatorService<R>>(Identifier)
const TRPCContextCreatorLive = Layer.effect(TRPCContextCreator, Effect.gen(function*() {
const runtime = yield* Effect.runtime<R>()
const mapErrors = yield* createTRCPErrorMapper
const run = <A, E>(
effect: Effect.Effect<A, E, R>,
options?: { readonly signal?: AbortSignal },
) => Runtime.runPromise(runtime)(
effect.pipe(mapErrors),
options,
)
const fork = <A, E>(
effect: Effect.Effect<A, E, R>,
options?: Runtime.RunForkOptions,
) => Runtime.runFork(runtime)(
effect.pipe(mapErrors),
options,
)
const createContext = (transaction: TRPCTransaction) => ({
runtime,
run,
fork,
transaction,
})
const createExpressContext = (context: CreateExpressContextOptions) => createContext(TRPCTransactionEnum.Express(context))
const createWebSocketContext = (context: CreateWSSContextFnOptions) => createContext(TRPCTransactionEnum.WebSocket(context))
return { createContext, createExpressContext, createWebSocketContext } as const
}))
return { TRPCContextCreator, TRPCContextCreatorLive } as const
}

View File

@@ -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<string>
}
) => 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,
})
)
}))

18
src/TRPC/TRPCRouter.ts Normal file
View File

@@ -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<T extends AnyRouter> extends Context.Tag<typeof Identifier, T> {}
export const TRPCAnyRouter = Context.GenericTag<typeof Identifier, AnyRouter>(Identifier)
export const make = <A extends AnyRouter, E, R>(
router: Effect.Effect<A, E, R>
) => {
const TRPCRouter = Context.GenericTag<typeof Identifier, A>(Identifier)
const TRPCRouterLive = Layer.effect(TRPCRouter, router)
return { TRPCRouter, TRPCRouterLive } as const
}

View File

@@ -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")<TRPCWebSocketServer, TRPCWebSocketServerService>() {}
export interface TRPCWebSocketServerService {
wss: ws.Server
handler: ReturnType<typeof applyWSSHandler>
}
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<string>
}
) => 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")))
})
}),
)
}))

View File

@@ -0,0 +1,63 @@
import { Effect, type Cause } from "effect"
import { importTRPCServer } from "./importTRPCServer"
export const createTRCPErrorMapper = importTRPCServer.pipe(Effect.map(({ TRPCError }) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) => 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<E> extends Error {
constructor(readonly cause: Cause.Cause<E>) {
super()
}
}

62
src/TRPC/example.ts Normal file
View File

@@ -0,0 +1,62 @@
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<Services>()
const { TRPCBuilder, TRPCBuilderLive } = TRPC.TRPCBuilder.make<Services>()
const router = Effect.gen(function*() {
const t = yield* TRPCBuilder
return t.router({
ping: t.procedure.query(({ ctx }) => ctx.run(
Effect.succeed("pong")
)),
expressOnlyProcedure: t.procedure
.use(yield* TRPC.ExpressOnly)
.query(({ ctx }) => ctx.run(Effect.gen(function*() {
ctx.transaction
}))),
})
})
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,
)

View File

@@ -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 }),
})

7
src/TRPC/index.ts Normal file
View File

@@ -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"

View File

@@ -0,0 +1,33 @@
import { Schema } from "@effect/schema"
import type { ParseOptions } from "@effect/schema/AST"
import { Effect } from "effect"
import { importTRPCServer } from "../importTRPCServer"
import type { TRPCContextRuntime } from "../TRPCContext"
export const DecodeInput = <A, I>(
schema: Schema.Schema<A, I>,
options?: ParseOptions,
) => Effect.gen(function*() {
const { experimental_standaloneMiddleware, TRPCError } = yield* importTRPCServer
const decode = (value: I) => Schema.decode(schema, options)(value).pipe(
Effect.matchEffect({
onSuccess: Effect.succeed,
onFailure: e => Effect.fail(new TRPCError({
code: "BAD_REQUEST",
message: "Could not decode input",
cause: e,
})),
})
)
return experimental_standaloneMiddleware<{
ctx: TRPCContextRuntime<never>
input: I
}>().create(
async ({ ctx, input, next }) => next({
ctx: { decodedInput: await ctx.run(decode(input)) } as const
})
)
})

View File

@@ -0,0 +1,24 @@
import { Effect, identity, Match } from "effect"
import { importTRPCServer } from "../importTRPCServer"
import type { TRPCContextTransaction } from "../TRPCContext"
export const ExpressOnly = importTRPCServer.pipe(Effect.map(({
experimental_standaloneMiddleware,
TRPCError,
}) => experimental_standaloneMiddleware<{
ctx: TRPCContextTransaction
}>().create(({ ctx, next }) => next({
ctx: {
transaction: Match.value(ctx.transaction).pipe(
Match.tag("Express", identity),
Match.orElse(() => {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only Express transport is supported by this procedure",
})
}),
)
}
}))))

View File

@@ -0,0 +1,24 @@
import { Effect, identity, Match } from "effect"
import { importTRPCServer } from "../importTRPCServer"
import type { TRPCContextTransaction } from "../TRPCContext"
export const WebSocketOnly = importTRPCServer.pipe(Effect.map(({
experimental_standaloneMiddleware,
TRPCError,
}) => experimental_standaloneMiddleware<{
ctx: TRPCContextTransaction
}>().create(({ ctx, next }) => next({
ctx: {
transaction: Match.value(ctx.transaction).pipe(
Match.tag("WebSocket", identity),
Match.orElse(() => {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only WebSocket transport is supported by this procedure",
})
}),
)
}
}))))

View File

@@ -0,0 +1,3 @@
export * from "./DecodeInput"
export * from "./ExpressOnly"
export * from "./WebSocketOnly"

View File

@@ -1,3 +1,7 @@
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 Schema from "./Schema"
export * as TRPC from "./TRPC"
export * as Types from "./Types" export * as Types from "./Types"

View File

@@ -6,13 +6,13 @@
"module": "ESNext", "module": "ESNext",
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, // "allowJs": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, // "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, // "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,
@@ -22,6 +22,12 @@
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false,
}
// Build
"outDir": "./dist",
"declaration": true
},
"include": ["./src"]
} }

View File

@@ -4,12 +4,16 @@ import { defineConfig } from "tsup"
export default defineConfig({ export default defineConfig({
entry: [ entry: [
"./src/index.ts", "./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/index.ts",
"./src/Schema/MobX/index.ts", "./src/Schema/MobX/index.ts",
"./src/Types/index.ts", "./src/Schema/TanStackForm/index.ts",
"./src/JSONWebToken.ts",
"./src/OpenAIClient.ts",
], ],
format: ["esm", "cjs"], format: ["esm"],
skipNodeModulesBundle: true, skipNodeModulesBundle: true,
dts: true, dts: true,
splitting: true, splitting: true,