Created
April 17, 2024 04:16
-
-
Save xesrevinu/ec6264c05f69d4e39a20301e555d03a1 to your computer and use it in GitHub Desktop.
effect-worker.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { WorkerEntrypoint } from "cloudflare:workers" | |
| import type { D1Database, ExecutionContext, KVNamespace, R2Bucket } from "@cloudflare/workers-types" | |
| import { Context, Data, Effect, Layer, pipe } from "effect" | |
| interface Env { | |
| KV: KVNamespace | |
| D1: D1Database | |
| BUCKET: R2Bucket | |
| AUTH_SERVICE: Service<AuthServiceWorker> | |
| } | |
| // AuthService.ts | |
| export class UserNotFound extends Data.TaggedError("UserNotFound") {} | |
| export interface UserInfo { username: string } | |
| export interface AuthService_ { // define service api | |
| readonly getUser: (token: string) => Promise<UserInfo | null> | |
| } | |
| export interface AuthService extends Effectify<AuthService_, RPCError> {} | |
| export const AuthService = Context.GenericTag<AuthService>("AuthService") // Effect based on Tag dependency injection | |
| export class AuthServiceWorker extends WorkerEntrypoint<Env> implements AuthService_ { // a remote rpc implementation | |
| async getUser(token: string): Promise<UserInfo | null> { | |
| // this.env.BUCKET | |
| // this.env.D1 | |
| return await this.env.KV.get<UserInfo>(token) | |
| } | |
| } | |
| export const AuthServiceTesting = Layer.succeed(AuthService, { // a testing implementation | |
| getUser: (token: string) => Effect.succeed({ username: "test" } satisfies UserInfo), | |
| }) | |
| // Your business logic ⬇️ | |
| // you don't need to care about the implementation details | |
| // and your can check the type of the effect | |
| // Success, Error Requirements | |
| // Effect.Effect<UserInfo, RPCError | BadRequest | UserNotFound, Request | AuthService> | |
| export const getUserInfo = Effect.gen(function* (_) { | |
| const request = yield* _(Request) | |
| const authService = yield* _(AuthService) | |
| const d1 = yield* _(D1) | |
| // recommend using @effect/schema to validate the request | |
| const { token } = yield* _( | |
| Effect.tryPromise({ | |
| try: () => request.json<{ token: string }>(), | |
| catch: () => new BadRequest(), | |
| }) | |
| ) | |
| yield* _(d1.exec( | |
| `INSERT INTO access_log ${token} getUserInfo` | |
| ), Effect.ignoreLogged) | |
| const user = yield* _(authService.getUser(token)) | |
| if (!user) { | |
| return yield* _(Effect.fail(new UserNotFound())) | |
| } | |
| return user | |
| }) | |
| // more ... | |
| // test.spec.ts ⬇️ | |
| it('get user info', async () => { | |
| const request = new Request("http://localhost/user", { | |
| body: JSON.stringify({ token: "test" }), | |
| }); | |
| const Testing = Layer.mergeAll( | |
| Layer.succeed(Request, request), | |
| // provide a testing implementation | |
| AuthServiceTesting, | |
| // local d1 | |
| D1Wrangler | |
| ) | |
| const result = await pipe(getUserInfo, Effect.provide(Testing), Effect.runPromise) | |
| expect(result).toEqual({ username: "test" }) | |
| }) | |
| // worker.ts ⬇️ | |
| export default { | |
| async fetch(request: Request, env: Env, ctx: ExecutionContext) { | |
| if (request.url === "/") { | |
| return new Response("Hello, world!") | |
| } | |
| if (request.url === "/user") { | |
| const Live = Layer.mergeAll( | |
| Layer.succeed(Request, request), | |
| rpcService(AuthService, () => env.AUTH_SERVICE), | |
| D1FromEnv(() => env.D1) | |
| ) | |
| return pipe( | |
| getUserInfo, | |
| Effect.map((_) => new Response(JSON.stringify(_))), | |
| Effect.catchTags({ | |
| BadRequest: () => Effect.succeed(new Response("Bad request", { status: 400 })), | |
| UserNotFound: () => Effect.succeed(new Response("User not found", { status: 404 })), | |
| RPCError: () => Effect.succeed(new Response("Internal server error", { status: 500 })), | |
| }), | |
| // unexpected error | |
| Effect.catchAllCause((cause) => Effect.succeed(new Response(cause.toString(), { status: 500 }))), | |
| Effect.provide(Live), | |
| Effect.runPromise | |
| ) | |
| } | |
| }, | |
| } | |
| export const Request = Context.GenericTag<Request>("Request") | |
| export class RPCError extends Data.TaggedError("RPCError")<{ readonly reason: unknown }> {} | |
| export class D1Error extends Data.TaggedError("D1Error")<{ readonly reason: unknown }> {} | |
| export class BadRequest extends Data.TaggedError("BadRequest")<{}> {} | |
| export type Effectify<T, E> = { | |
| [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Effect.Effect<Awaited<R>, E> : never | |
| } | |
| export const wrapService = <T extends object, E>(service: T): Effectify<T, E> => | |
| new Proxy(service, { | |
| get(target, prop) { | |
| // can do better | |
| return (...args: any) => | |
| Effect.mapError( | |
| Effect.promise(() => target[prop](...args)), | |
| (err) => new RPCError({ reason: err }), | |
| ) | |
| }, | |
| }) as Effectify<T, E> | |
| export const rpcService = <S extends Service, T extends Context.Tag<any, any>>(tag: T , service: () => S) => { | |
| let wrap = wrapService<S,RPCError>(service()) | |
| return Layer.succeed(tag, wrap as any) | |
| } | |
| export interface D1 { | |
| exec: (query: string) => Effect.Effect<D1ExecResult, D1Error> | |
| } | |
| export const D1 = Context.GenericTag<D1>("D1") | |
| export const D1FromEnv = (d1: () => D1Database) => { | |
| const database = d1(); | |
| return Layer.succeed(D1, { | |
| exec: (sql) => Effect.promise(() => database.exec(sql)) | |
| }) | |
| } | |
| export const D1Wrangler = Layer.effect(D1, Effect.gen(function* (_) { | |
| const miniflare = new Miniflare({ | |
| // ... | |
| }) | |
| const env = Effect.promise(() => miniflare.getBindings()) | |
| return { | |
| exec: (query: string) => Effect.promise(() => env.D1.exec(query)) | |
| } | |
| })) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment