feat: add effect api

This commit is contained in:
Max Koon
2025-11-26 00:32:18 -05:00
parent 2df7f2d924
commit 371f5e879b
8 changed files with 276 additions and 89 deletions

View File

@@ -7,9 +7,12 @@
"start": "tsx src/index.ts"
},
"dependencies": {
"@effect/platform": "^0.93.2",
"@effect/platform-node": "^0.101.1",
"@hono/node-server": "^1.19.5",
"@money/shared": "workspace:*",
"better-auth": "^1.3.27",
"effect": "^3.19.4",
"hono": "^4.9.12",
"plaid": "^39.0.0",
"tsx": "^4.20.6"

View File

@@ -1,58 +1,122 @@
import { serve } from "@hono/node-server";
import { authDataSchema } from "@money/shared/auth";
import { BASE_URL } from "@money/shared";
import { cors } from "hono/cors";
import * as Layer from "effect/Layer";
import * as Effect from "effect/Effect";
import * as Context from "effect/Context";
import * as HttpLayerRouter from "@effect/platform/HttpLayerRouter";
import * as HttpServerResponse from "@effect/platform/HttpServerResponse";
import * as HttpServerRequest from "@effect/platform/HttpServerRequest";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import { createServer } from "http";
import {
HttpApiBuilder,
HttpApiSchema,
HttpApiSecurity,
} from "@effect/platform";
import { Schema, Data, Console } from "effect";
import { auth } from "./auth";
import { getHono } from "./hono";
import { zero } from "./zero";
import { webhook } from "./webhook";
import { AuthSchema } from "@money/shared/auth";
const app = getHono();
class CurrentSession extends Context.Tag("CurrentSession")<
CurrentSession,
{ readonly auth: Schema.Schema.Type<typeof AuthSchema> | null }
>() {}
app.use(
"/api/*",
cors({
origin: ["https://money.koon.us", `${BASE_URL}:8081`],
allowMethods: ["POST", "GET", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
class Unauthorized extends Schema.TaggedError<Unauthorized>()(
"Unauthorized",
{},
HttpApiSchema.annotations({ status: 401 }),
) {}
const parseAuthorization = (input: string) =>
Effect.gen(function* () {
const m = /^Bearer\s+(.+)$/.exec(input);
if (!m) {
return yield* Effect.fail(
new Unauthorized({ message: "Invalid Authorization header" }),
);
}
return m[1];
});
export class Authorization extends Context.Tag("Authorization")<
Authorization,
AuthorizationImpl
>() {}
export interface AuthorizationImpl {
use: <T>(
fn: (client: typeof auth) => Promise<T>,
) => Effect.Effect<T, AuthorizationUnknownError | AuthorizationError, never>;
}
class AuthorizationUnknownError extends Data.TaggedError(
"AuthClientUnknownError",
) {}
class AuthorizationError extends Data.TaggedError("AuthClientFetchError")<{
message: string;
}> {}
export const make = () =>
Effect.gen(function* () {
return Authorization.of({
use: (fn) =>
Effect.gen(function* () {
const data = yield* Effect.tryPromise({
try: () => fn(auth),
catch: (error) =>
error instanceof Error
? new AuthorizationError({ message: error.message })
: new AuthorizationUnknownError(),
});
return data;
}),
});
});
export const AuthorizationLayer = Layer.scoped(Authorization, make());
const SessionMiddleware = HttpLayerRouter.middleware<{
provides: CurrentSession;
}>()(
Effect.gen(function* () {
const auth = yield* Authorization;
return (httpEffect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const headers = request.headers;
const session = yield* auth
.use((auth) => auth.api.getSession({ headers }))
.pipe(
Effect.flatMap((s) =>
s == null ? Effect.succeed(null) : Schema.decode(AuthSchema)(s),
),
Effect.tap((s) => Console.debug("Auth result", s)),
);
return yield* Effect.provideService(httpEffect, CurrentSession, {
auth: session,
});
});
}),
);
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
const HelloRoute = HttpLayerRouter.add(
"GET",
"/hello",
app.use("*", async (c, next) => {
const authHeader = c.req.raw.headers.get("Authorization");
const cookie = authHeader?.split("Bearer ")[1];
Effect.gen(function* () {
const { auth } = yield* CurrentSession;
return HttpServerResponse.text(`Hello, your name is ${auth?.user.name}`);
}),
).pipe(Layer.provide(SessionMiddleware.layer));
const newHeaders = new Headers(c.req.raw.headers);
const AllRoutes = Layer.mergeAll(HelloRoute);
if (cookie) {
newHeaders.set("Cookie", cookie);
}
const session = await auth.api.getSession({ headers: newHeaders });
if (!session) {
c.set("auth", null);
return next();
}
c.set("auth", authDataSchema.parse(session));
return next();
});
app.route("/api/zero", zero);
app.get("/api", (c) => c.text("OK"));
app.get("/api/webhook_receiver", webhook);
app.get("/", (c) => c.text("OK"));
serve(
{
fetch: app.fetch,
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
},
(info) => {
console.log(`Server is running on ${info.address}:${info.port}`);
},
HttpLayerRouter.serve(AllRoutes).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
Layer.provide(AuthorizationLayer),
Layer.launch,
NodeRuntime.runMain,
);

58
apps/api/src/index_old.ts Normal file
View File

@@ -0,0 +1,58 @@
import { serve } from "@hono/node-server";
import { authDataSchema } from "@money/shared/auth";
import { BASE_URL } from "@money/shared";
import { cors } from "hono/cors";
import { auth } from "./auth";
import { getHono } from "./hono";
import { zero } from "./zero";
import { webhook } from "./webhook";
const app = getHono();
app.use(
"/api/*",
cors({
origin: ["https://money.koon.us", `${BASE_URL}:8081`],
allowMethods: ["POST", "GET", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
);
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
app.use("*", async (c, next) => {
const authHeader = c.req.raw.headers.get("Authorization");
const cookie = authHeader?.split("Bearer ")[1];
const newHeaders = new Headers(c.req.raw.headers);
if (cookie) {
newHeaders.set("Cookie", cookie);
}
const session = await auth.api.getSession({ headers: newHeaders });
if (!session) {
c.set("auth", null);
return next();
}
c.set("auth", authDataSchema.parse(session));
return next();
});
app.route("/api/zero", zero);
app.get("/api", (c) => c.text("OK"));
app.get("/api/webhook_receiver", webhook);
app.get("/", (c) => c.text("OK"));
serve(
{
fetch: app.fetch,
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
},
(info) => {
console.log(`Server is running on ${info.address}:${info.port}`);
},
);