From b309d5aadb5ba807825b0ddb4078d58e06a4d518 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:42:41 +0300 Subject: [PATCH 01/28] format: order errors by the error code --- apps/backend/src/errors/index.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/errors/index.ts b/apps/backend/src/errors/index.ts index 619a104..8625d41 100644 --- a/apps/backend/src/errors/index.ts +++ b/apps/backend/src/errors/index.ts @@ -13,25 +13,21 @@ export class GenericError extends Error { } } -export class AuthError extends GenericError { +export class BadRequestError extends GenericError { constructor(message: string) { - super(ReasonPhrases.UNAUTHORIZED, StatusCodes.UNAUTHORIZED, message); + super(ReasonPhrases.BAD_REQUEST, StatusCodes.BAD_REQUEST, message); } } -export class BadRequestError extends GenericError { +export class AuthError extends GenericError { constructor(message: string) { - super(ReasonPhrases.BAD_REQUEST, StatusCodes.BAD_REQUEST, message); + super(ReasonPhrases.UNAUTHORIZED, StatusCodes.UNAUTHORIZED, message); } } -export class ServerError extends GenericError { +export class NotFoundError extends GenericError { constructor(message: string) { - super( - ReasonPhrases.INTERNAL_SERVER_ERROR, - StatusCodes.INTERNAL_SERVER_ERROR, - message, - ); + super(ReasonPhrases.NOT_FOUND, StatusCodes.NOT_FOUND, message); } } @@ -40,13 +36,17 @@ export class RateLimitError extends GenericError { super( ReasonPhrases.TOO_MANY_REQUESTS, StatusCodes.TOO_MANY_REQUESTS, - message, + message ); } } -export class NotFoundError extends GenericError { +export class ServerError extends GenericError { constructor(message: string) { - super(ReasonPhrases.NOT_FOUND, StatusCodes.NOT_FOUND, message); + super( + ReasonPhrases.INTERNAL_SERVER_ERROR, + StatusCodes.INTERNAL_SERVER_ERROR, + message + ); } } From 5b216332b353199d1e755be24e863b69e5fb8477 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:44:59 +0300 Subject: [PATCH 02/28] refactor: use zod.safeParse --- apps/backend/src/env/index.ts | 9 ++++---- apps/backend/src/middlewares/validation.ts | 26 +++++++++------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/apps/backend/src/env/index.ts b/apps/backend/src/env/index.ts index 47d85b7..d52739f 100644 --- a/apps/backend/src/env/index.ts +++ b/apps/backend/src/env/index.ts @@ -75,10 +75,9 @@ const envSchema = z.object({ }); const envValidate = () => { - try { - envSchema.parse(process.env); - } catch (error) { - if (error instanceof ZodError) console.error(error.errors); + const { error } = envSchema.safeParse(process.env); + if (error) { + console.error(error.errors); process.exit(1); } }; @@ -87,7 +86,7 @@ declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace NodeJS { // eslint-disable-next-line @typescript-eslint/no-empty-object-type - interface ProcessEnv extends z.infer {} + interface ProcessEnv extends z.infer {} } } diff --git a/apps/backend/src/middlewares/validation.ts b/apps/backend/src/middlewares/validation.ts index 03eba60..4f48294 100644 --- a/apps/backend/src/middlewares/validation.ts +++ b/apps/backend/src/middlewares/validation.ts @@ -4,22 +4,18 @@ import { BadRequestError } from "@repo/backend/errors"; const validateHandler = (schema: z.ZodSchema) => { return (req: Request, res: Response, next: NextFunction) => { - try { - schema.parse(req); - next(); - } catch (error) { - if (error instanceof ZodError) { - const errorMessages = error.errors - .map( - (issue: z.ZodIssue) => - `${issue.path.join(".")} is ${issue.message}`, - ) - .join(", "); - throw new BadRequestError(errorMessages); - } else { - next(error); - } + const { success, error } = schema.safeParse(req); + + if (!success) { + const errorMessages = error.errors + .map( + (issue: z.ZodIssue) => `${issue.path.join(".")} is ${issue.message}` + ) + .join(", "); + throw new BadRequestError(errorMessages); } + + next(); }; }; From e4fab249cb0fb5a598c22eb00cc848e8e33e4e73 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:45:24 +0300 Subject: [PATCH 03/28] format: remove trailing comma --- apps/backend/src/middlewares/error.ts | 2 +- apps/backend/src/modules/auth/auth.route.ts | 6 +++--- apps/backend/src/server.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/middlewares/error.ts b/apps/backend/src/middlewares/error.ts index e130a23..7a197f9 100644 --- a/apps/backend/src/middlewares/error.ts +++ b/apps/backend/src/middlewares/error.ts @@ -6,7 +6,7 @@ const errorHandler: ErrorRequestHandler = ( error: unknown, req: Request, res: Response, - next: NextFunction, + next: NextFunction ) => { console.error(error); diff --git a/apps/backend/src/modules/auth/auth.route.ts b/apps/backend/src/modules/auth/auth.route.ts index 4127360..315dd1e 100644 --- a/apps/backend/src/modules/auth/auth.route.ts +++ b/apps/backend/src/modules/auth/auth.route.ts @@ -28,7 +28,7 @@ authRouter.post( "/otp/send", sendOtpLimiter, validateHandler(sendOtpSchema), - sendOtpController, + sendOtpController ); const verifyOtplimiter = rateLimitHandler({ @@ -40,7 +40,7 @@ const verifyOtplimiter = rateLimitHandler({ authRouter.post( "/otp/status", validateHandler(statusOtpSchema), - checkOtpStatusController, + checkOtpStatusController ); authRouter.post( @@ -53,7 +53,7 @@ authRouter.post( authRouter.get( "/refresh-token", validateHandler(refreshTokenSchema), - refreshTokenController, + refreshTokenController ); authRouter.get("/logout", logoutController); diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index 1c1004b..4b4d98d 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -14,8 +14,8 @@ export const createServer = (): Express => { .use( morgan( `:remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length]`, - { immediate: true }, - ), + { immediate: true } + ) ) .use(express.urlencoded({ extended: true })) .use(express.json()) @@ -25,7 +25,7 @@ export const createServer = (): Express => { rateLimitHandler({ timeSpan: process.env.WINDOW_SIZE_IN_MINUTES, limit: process.env.MAX_NUMBER_OF_REQUESTS_PER_WINDOW_SIZE, - }), + }) ) .get("/healthcheck", (_req, res) => { res.json({ From 7f0676aaed6b17e79329b96ac3661ffe4e681010 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:45:43 +0300 Subject: [PATCH 04/28] feat: add block module --- .../src/modules/block/block.controller.ts | 36 +++++++++++++ apps/backend/src/modules/block/block.route.ts | 29 +++++++++++ .../backend/src/modules/block/block.schema.ts | 9 ++++ .../src/modules/block/block.service.ts | 52 +++++++++++++++++++ apps/backend/src/modules/index.ts | 2 + 5 files changed, 128 insertions(+) create mode 100644 apps/backend/src/modules/block/block.controller.ts create mode 100644 apps/backend/src/modules/block/block.route.ts create mode 100644 apps/backend/src/modules/block/block.schema.ts create mode 100644 apps/backend/src/modules/block/block.service.ts diff --git a/apps/backend/src/modules/block/block.controller.ts b/apps/backend/src/modules/block/block.controller.ts new file mode 100644 index 0000000..0d2a084 --- /dev/null +++ b/apps/backend/src/modules/block/block.controller.ts @@ -0,0 +1,36 @@ +import { Response } from "express"; +import { StatusCodes } from "http-status-codes"; +import { AuthenticatedRequest } from "@repo/backend/middlewares/auth"; +import { getBlockedUsers, blockUser, unblockUser } from "./block.service"; + +const getBlockedUsersController = async ( + req: AuthenticatedRequest, + res: Response +) => { + const blockedUsers = await getBlockedUsers(req.userId); + res.status(StatusCodes.OK).json({ blockedUsers }); +}; + +const blockUserController = async ( + req: AuthenticatedRequest, + res: Response +) => { + const { blockUserId } = req.params; + await blockUser(req.userId, parseInt(blockUserId)); + res.status(StatusCodes.OK).json({ message: "User blocked successfully." }); +}; + +const unblockUserController = async ( + req: AuthenticatedRequest, + res: Response +) => { + const { unblockUserId } = req.params; + await unblockUser(req.userId, parseInt(unblockUserId)); + res.status(StatusCodes.OK).json({ message: "User unblocked successfully." }); +}; + +export { + getBlockedUsersController, + blockUserController, + unblockUserController, +}; diff --git a/apps/backend/src/modules/block/block.route.ts b/apps/backend/src/modules/block/block.route.ts new file mode 100644 index 0000000..186cfc4 --- /dev/null +++ b/apps/backend/src/modules/block/block.route.ts @@ -0,0 +1,29 @@ +import { Router, Response } from "express"; +import { AuthenticatedRequest } from "@repo/backend/middlewares/auth"; +import validateHandler from "@repo/backend/middlewares/validation"; +import { + getBlockedUsersController, + blockUserController, + unblockUserController, +} from "./block.controller"; +import { blockSchema } from "./block.schema"; + +const blockRouter = Router(); + +blockRouter.get("/", async (req, res) => { + await getBlockedUsersController(req as AuthenticatedRequest, res as Response); +}); + +blockRouter.post("/:userId", validateHandler(blockSchema), async (req, res) => { + await blockUserController(req as AuthenticatedRequest, res as Response); +}); + +blockRouter.delete( + "/:userId", + validateHandler(blockSchema), + async (req, res) => { + await unblockUserController(req as AuthenticatedRequest, res as Response); + } +); + +export default blockRouter; diff --git a/apps/backend/src/modules/block/block.schema.ts b/apps/backend/src/modules/block/block.schema.ts new file mode 100644 index 0000000..77dfc80 --- /dev/null +++ b/apps/backend/src/modules/block/block.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +const blockSchema = z.object({ + params: z.object({ + userId: z.coerce.number().int(), + }), +}); + +export { blockSchema }; diff --git a/apps/backend/src/modules/block/block.service.ts b/apps/backend/src/modules/block/block.service.ts new file mode 100644 index 0000000..f7fde2d --- /dev/null +++ b/apps/backend/src/modules/block/block.service.ts @@ -0,0 +1,52 @@ +import { and, eq, not, or } from "drizzle-orm"; +import { db } from "@repo/database"; +import { blockedUsers, friends, users } from "@repo/database/schema"; +import { BadRequestError } from "@repo/backend/errors"; + +const getBlockedUsers = async (userId: number) => { + return await db.query.blockedUsers.findMany({ + where: eq(blockedUsers.blockerId, userId), + with: { + blocked: true, + }, + }); +}; + +const blockUser = async (userId: number, blockUserId: number) => { + if (userId === blockUserId) + throw new BadRequestError("You can't block yourself."); + + const blockedUser = await db.query.blockedUsers.findFirst({ + where: and( + eq(blockedUsers.blockerId, userId), + eq(blockedUsers.blockedId, blockUserId) + ), + }); + + if (blockedUser) throw new BadRequestError("You already blocked this user."); + + await db.insert(blockedUsers).values({ + blockerId: userId, + blockedId: blockUserId, + }); +}; + +const unblockUser = async (userId: number, unblockUserId: number) => { + if (userId === unblockUserId) + throw new BadRequestError("You can't unblock yourself."); + + const result = await db + .delete(friends) + .where( + and( + eq(blockedUsers.blockerId, userId), + eq(blockedUsers.blockedId, unblockUserId) + ) + ) + .returning(); + + if (result.length === 0) + throw new BadRequestError("User not found or already unblocked."); +}; + +export { getBlockedUsers, blockUser, unblockUser }; diff --git a/apps/backend/src/modules/index.ts b/apps/backend/src/modules/index.ts index f9eb6c2..5f0de2a 100644 --- a/apps/backend/src/modules/index.ts +++ b/apps/backend/src/modules/index.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import authHandler from "@repo/backend/middlewares/auth"; +import blockRouter from "@repo/backend/modules/block/block.route"; import authRouter from "@repo/backend/modules/auth/auth.route"; import chatRouter from "@repo/backend/modules/chat/chat.route"; import friendsRouter from "@repo/backend/modules/friends/friends.route"; @@ -8,6 +9,7 @@ import userRouter from "@repo/backend/modules/user/user.route"; const rootRouter = Router(); rootRouter.use("/auth", authRouter); +rootRouter.use("/block", authHandler, blockRouter); rootRouter.use("/chat", authHandler, chatRouter); rootRouter.use("/friends", authHandler, friendsRouter); rootRouter.use("/user", authHandler, userRouter); From 19b1a2d744f696c0785c97e0689cb229df6ac320 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:53:23 +0300 Subject: [PATCH 05/28] fix: missing async and await --- .../src/modules/friends/friends.route.ts | 20 +++++++++---------- apps/backend/src/modules/user/user.route.ts | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/modules/friends/friends.route.ts b/apps/backend/src/modules/friends/friends.route.ts index 5ddfe8c..ac60b1b 100644 --- a/apps/backend/src/modules/friends/friends.route.ts +++ b/apps/backend/src/modules/friends/friends.route.ts @@ -12,35 +12,35 @@ import { friendsSchema } from "./friends.schema"; const friendsRouter = Router(); -friendsRouter.get("/", (req, res) => { - getFriendsController(req as AuthenticatedRequest, res as Response); +friendsRouter.get("/", async (req, res) => { + await getFriendsController(req as AuthenticatedRequest, res as Response); }); -friendsRouter.post("/:friendId", validateHandler(friendsSchema), (req, res) => { - addFriendController(req as AuthenticatedRequest, res as Response); +friendsRouter.post("/:friendId", validateHandler(friendsSchema), async (req, res) => { + await addFriendController(req as AuthenticatedRequest, res as Response); }); friendsRouter.delete( "/:friendId", validateHandler(friendsSchema), - (req, res) => { - deleteFriendController(req as AuthenticatedRequest, res as Response); + async (req, res) => { + await deleteFriendController(req as AuthenticatedRequest, res as Response); }, ); friendsRouter.post( "/:friendId/accept", validateHandler(friendsSchema), - (req, res) => { - acceptFriendRequestController(req as AuthenticatedRequest, res as Response); + async (req, res) => { + await acceptFriendRequestController(req as AuthenticatedRequest, res as Response); }, ); friendsRouter.post( "/:friendId/deny", validateHandler(friendsSchema), - (req, res) => { - denyFriendRequestController(req as AuthenticatedRequest, res as Response); + async (req, res) => { + await denyFriendRequestController(req as AuthenticatedRequest, res as Response); }, ); diff --git a/apps/backend/src/modules/user/user.route.ts b/apps/backend/src/modules/user/user.route.ts index cdc05ec..fc1a7b3 100644 --- a/apps/backend/src/modules/user/user.route.ts +++ b/apps/backend/src/modules/user/user.route.ts @@ -6,12 +6,12 @@ import { updateUserSchema } from "./user.schema"; const userRouter = Router(); -userRouter.get("/", (req, res) => { - getUserController(req as AuthenticatedRequest, res as Response); +userRouter.get("/", async (req, res) => { + await getUserController(req as AuthenticatedRequest, res as Response); }); -userRouter.patch("/", validateHandler(updateUserSchema), (req, res) => { - updateUserController(req as AuthenticatedRequest, res as Response); +userRouter.patch("/", validateHandler(updateUserSchema), async (req, res) => { + await updateUserController(req as AuthenticatedRequest, res as Response); }); export default userRouter; From 2b23141d7d7ef1415ab17e119d00e0af71aa26c3 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:53:49 +0300 Subject: [PATCH 06/28] fix: check for "true" not true --- apps/backend/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 2c5b821..ca99d1a 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,7 +5,7 @@ import { dbPush, dbReset, dbSeed, dbWaitForConnection } from "@repo/database"; envValidate(); await dbWaitForConnection(); -if (process.env.NODE_ENV === "development" && process.env.RESET_DB === true) { +if (process.env.NODE_ENV === "development" && process.env.RESET_DB === "true") { await dbPush(); await dbReset(); await dbSeed(); From 996d4c8dbdf71c781f81daa9b7ff56451d77c8ad Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:57:37 +0300 Subject: [PATCH 07/28] feat: add verify route --- .../backend/src/modules/auth/auth.controller.ts | 6 ++++++ apps/backend/src/modules/auth/auth.route.ts | 6 +++++- apps/backend/src/modules/auth/auth.schema.ts | 14 +++++++++++++- apps/backend/src/modules/auth/auth.service.ts | 17 ++++++++++++++++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index 136b0df..483defe 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { sendOtp, checkOtpStatus, verifyOtp, + verify, refreshAccessToken, logout, } from "./auth.service"; @@ -38,6 +39,10 @@ const verifyOtpController = async (req: Request, res: Response) => { maxAge: process.env.JWT_REFRESH_TOKEN_MAX_AGE, }); res.status(StatusCodes.CREATED).json({ accessToken }); +const verifyController = async (req: Request, res: Response) => { + const accessToken = req.cookies[process.env.JWT_ACCESS_TOKEN_COOKIE_KEY]; + await verify(accessToken); + res.status(StatusCodes.OK).json({}); }; const refreshTokenController = async (req: Request, res: Response) => { @@ -64,6 +69,7 @@ export { sendOtpController, checkOtpStatusController, verifyOtpController, + verifyController, refreshTokenController, logoutController, }; diff --git a/apps/backend/src/modules/auth/auth.route.ts b/apps/backend/src/modules/auth/auth.route.ts index 315dd1e..472bbd7 100644 --- a/apps/backend/src/modules/auth/auth.route.ts +++ b/apps/backend/src/modules/auth/auth.route.ts @@ -5,12 +5,14 @@ import { sendOtpController, checkOtpStatusController, verifyOtpController, + verifyController, refreshTokenController, logoutController, } from "./auth.controller"; import { sendOtpSchema, verifyOtpSchema, + verifySchema, refreshTokenSchema, statusOtpSchema, } from "./auth.schema"; @@ -47,9 +49,11 @@ authRouter.post( "/otp/verify", verifyOtplimiter, validateHandler(verifyOtpSchema), - verifyOtpController, + verifyOtpController ); +authRouter.get("/verify", validateHandler(verifySchema), verifyController); + authRouter.get( "/refresh-token", validateHandler(refreshTokenSchema), diff --git a/apps/backend/src/modules/auth/auth.schema.ts b/apps/backend/src/modules/auth/auth.schema.ts index 2077b6f..694a5c1 100644 --- a/apps/backend/src/modules/auth/auth.schema.ts +++ b/apps/backend/src/modules/auth/auth.schema.ts @@ -18,10 +18,22 @@ const verifyOtpSchema = sendOtpSchema.extend({ }), }); +const verifySchema = z.object({ + cookies: z.object({ + accessToken: z.string().min(1), + }), +}); + const refreshTokenSchema = z.object({ cookies: z.object({ refreshToken: z.string().min(1), }), }); -export { sendOtpSchema, statusOtpSchema, verifyOtpSchema, refreshTokenSchema }; +export { + sendOtpSchema, + statusOtpSchema, + verifyOtpSchema, + verifySchema, + refreshTokenSchema, +}; diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index faf6613..f3f5c28 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -8,6 +8,7 @@ import { AuthError } from "@repo/backend/errors"; import { jwtSignAccessToken, jwtSignRefreshToken, + jwtVerifyAccessToken, jwtVerifyRefreshToken, } from "@repo/backend/utils/jwt"; @@ -97,6 +98,13 @@ const verifyOtp = async (phoneNumber: string, otp: string) => { return { accessToken, refreshToken }; }; +const verify = async (accessToken: string) => { + const [error] = await attempt(() => jwtVerifyAccessToken(accessToken)); + if (error) throw new AuthError("Invalid access token."); + + return true; +}; + const refreshAccessToken = async (refreshToken: string) => { try { const { userId } = jwtVerifyRefreshToken(refreshToken); @@ -135,4 +143,11 @@ const logout = async (refreshToken: string) => { } }; -export { sendOtp, checkOtpStatus, verifyOtp, refreshAccessToken, logout }; +export { + sendOtp, + checkOtpStatus, + verifyOtp, + verify, + refreshAccessToken, + logout, +}; From 2c867258784e9a4b938006c7bdf1a309622fb144 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 19:58:54 +0300 Subject: [PATCH 08/28] refactor: use better error handling --- apps/backend/src/modules/auth/auth.service.ts | 62 +++++++++---------- apps/backend/src/utils/index.ts | 18 ++++++ 2 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 apps/backend/src/utils/index.ts diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index f3f5c28..65cd362 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -11,10 +11,11 @@ import { jwtVerifyAccessToken, jwtVerifyRefreshToken, } from "@repo/backend/utils/jwt"; +import { attempt } from "@repo/backend/utils"; const twilioClient = twilio( process.env.TWILIO_ACCOUNT_SID, - process.env.TWILIO_AUTH_TOKEN, + process.env.TWILIO_AUTH_TOKEN ); const sendOtp = async (phoneNumber: string) => { @@ -33,11 +34,11 @@ const sendOtp = async (phoneNumber: string) => { set: { otp, expiresAt, updatedAt: new Date() }, }); - await twilioClient.messages.create({ - body: `Your OTP is: ${otp}`, - from: process.env.TWILIO_PHONE_NUMBER, - to: `+${phoneNumber}`, - }); + // await twilioClient.messages.create({ + // body: `Your OTP is: ${otp}`, + // from: process.env.TWILIO_PHONE_NUMBER, + // to: `+${phoneNumber}`, + // }); }; const checkOtpStatus = async (phoneNumber: string) => { @@ -106,41 +107,36 @@ const verify = async (accessToken: string) => { }; const refreshAccessToken = async (refreshToken: string) => { - try { - const { userId } = jwtVerifyRefreshToken(refreshToken); - if (!userId) throw new AuthError("Invalid refresh token"); + const [error, data] = await attempt(() => + jwtVerifyRefreshToken(refreshToken) + ); + if (error) throw new AuthError(error.message); - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - }); - if (!user) throw new AuthError("Invalid refresh token"); + const user = await db.query.users.findFirst({ + where: eq(users.id, data.userId), + }); + if (!user) throw new AuthError("Invalid refresh token"); - const storedToken = await db.query.refreshTokens.findFirst({ - where: eq(refreshTokens.userId, userId), - }); - if (!storedToken) throw new AuthError("Invalid refresh token"); + const storedToken = await db.query.refreshTokens.findFirst({ + where: eq(refreshTokens.userId, data.userId), + }); + if (!storedToken) throw new AuthError("Invalid refresh token."); - const isTokenValid = await argon2.verify(storedToken.token, refreshToken); - if (!isTokenValid) { - throw new AuthError("Invalid refresh token"); - } + const isTokenValid = await argon2.verify(storedToken.token, refreshToken); + if (!isTokenValid) throw new AuthError("isTokenValid"); - return jwtSignAccessToken({ userId: user.id }); - } catch (error) { - if (error instanceof Error) throw new AuthError(error.message); - else throw new AuthError("Invalid refresh token"); - } + const accessToken = jwtSignAccessToken({ userId: user.id }); + + return { accessToken }; }; const logout = async (refreshToken: string) => { - try { - const { userId } = jwtVerifyRefreshToken(refreshToken); - if (!userId) return; + const [error, data] = await attempt(() => + jwtVerifyRefreshToken(refreshToken) + ); + if (error) return; - await db.delete(refreshTokens).where(eq(refreshTokens.userId, userId)); - } catch { - return; - } + await db.delete(refreshTokens).where(eq(refreshTokens.userId, data.userId)); }; export { diff --git a/apps/backend/src/utils/index.ts b/apps/backend/src/utils/index.ts new file mode 100644 index 0000000..3e79e52 --- /dev/null +++ b/apps/backend/src/utils/index.ts @@ -0,0 +1,18 @@ +type Success = [null, T]; +type Failure = [E, null]; +type Result = Success | Failure; + +const attempt = async ( + fn: (() => T) | Promise +): Promise> => { + try { + const data = await (fn instanceof Promise + ? fn + : Promise.resolve().then(fn)); + return [null, data]; + } catch (error) { + return [error as E, null]; + } +}; + +export { attempt }; From 187f4d7e552c384b4f01440d6270b102f97275e8 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 20:31:22 +0300 Subject: [PATCH 09/28] refactor: use .all for the /api/ paths --- apps/backend/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index 4b4d98d..53d1106 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -34,7 +34,7 @@ export const createServer = (): Express => { timestamp: Date.now(), }); }) - .use("/api/v1", rootRouter) + .all("/api/v1", rootRouter) .all("/*splat", () => { throw new NotFoundError("You look a little lost."); }) From 4fddddeffa6b9f7e43a965303db53d7c872cd4a0 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 20:32:00 +0300 Subject: [PATCH 10/28] refactor: extract access token from cookie rather than body --- apps/backend/src/middlewares/auth.ts | 22 +++++++++---------- .../src/modules/auth/auth.controller.ts | 14 +++++++++--- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/middlewares/auth.ts b/apps/backend/src/middlewares/auth.ts index c84d53e..4428028 100644 --- a/apps/backend/src/middlewares/auth.ts +++ b/apps/backend/src/middlewares/auth.ts @@ -1,27 +1,25 @@ import { Request, Response, NextFunction } from "express"; import { AuthError } from "@repo/backend/errors"; import { jwtVerifyAccessToken } from "@repo/backend/utils/jwt"; +import { attempt } from "@repo/backend/utils"; export interface AuthenticatedRequest extends Request { userId: number; } const authHandler = async (req: Request, res: Response, next: NextFunction) => { - const { authorization } = req.headers; - if (!authorization) throw new AuthError("Authorization header not found."); + // const { authorization } = req.headers; + // if (!authorization) throw new AuthError("Authorization header not found."); - const token = authorization.split(" ")[1]; - if (!token) throw new AuthError("No token provided."); + // const token = authorization.split(" ")[1]; + // if (!token) throw new AuthError("No token provided."); - try { - const { userId } = jwtVerifyAccessToken(token); - if (!userId) throw new AuthError("Invalid token."); + const token = req.cookies[process.env.JWT_ACCESS_TOKEN_COOKIE_KEY]; - (req as AuthenticatedRequest).userId = userId; - } catch (error: unknown) { - if (error instanceof Error) throw new AuthError(error.message); - else throw new AuthError("Invalid token."); - } + const [error, data] = await attempt(() => jwtVerifyAccessToken(token)); + if (error) throw new AuthError(error.message); + + (req as AuthenticatedRequest).userId = data.userId; next(); }; diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index 483defe..01f52c5 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -38,7 +38,9 @@ const verifyOtpController = async (req: Request, res: Response) => { sameSite: "lax", maxAge: process.env.JWT_REFRESH_TOKEN_MAX_AGE, }); - res.status(StatusCodes.CREATED).json({ accessToken }); + res.status(StatusCodes.CREATED).json({}); +}; + const verifyController = async (req: Request, res: Response) => { const accessToken = req.cookies[process.env.JWT_ACCESS_TOKEN_COOKIE_KEY]; await verify(accessToken); @@ -47,14 +49,20 @@ const verifyController = async (req: Request, res: Response) => { const refreshTokenController = async (req: Request, res: Response) => { const refreshToken = req.cookies[process.env.JWT_REFRESH_TOKEN_COOKIE_KEY]; - const accessToken = await refreshAccessToken(refreshToken); + const { accessToken } = await refreshAccessToken(refreshToken); res.cookie(process.env.JWT_ACCESS_TOKEN_COOKIE_KEY, accessToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: process.env.JWT_ACCESS_TOKEN_MAX_AGE, }); - res.status(StatusCodes.OK).json({ accessToken }); + // res.cookie(process.env.JWT_REFRESH_TOKEN_COOKIE_KEY, newRefreshToken, { + // httpOnly: true, + // secure: process.env.NODE_ENV === "production", + // sameSite: "lax", + // maxAge: process.env.JWT_REFRESH_TOKEN_MAX_AGE, + // }); + res.status(StatusCodes.CREATED).json({}); }; const logoutController = async (req: Request, res: Response) => { From 1e5d363a0f7aba98352d56403416777e80ef36e8 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 23 Apr 2025 21:25:14 +0300 Subject: [PATCH 11/28] refactor: chat module --- .../src/modules/chat/chat.controller.ts | 25 +- apps/backend/src/modules/chat/chat.route.ts | 28 +- apps/backend/src/modules/chat/chat.schema.ts | 2 +- apps/backend/src/modules/chat/chat.service.ts | 355 +++++++++++++++--- 4 files changed, 350 insertions(+), 60 deletions(-) diff --git a/apps/backend/src/modules/chat/chat.controller.ts b/apps/backend/src/modules/chat/chat.controller.ts index fc971e3..f098748 100644 --- a/apps/backend/src/modules/chat/chat.controller.ts +++ b/apps/backend/src/modules/chat/chat.controller.ts @@ -1,6 +1,6 @@ import { Response } from "express"; import { StatusCodes } from "http-status-codes"; -import { getChats, getChatDetails } from "./chat.service"; +import { getChats, getChatDetails, getChatMessages } from "./chat.service"; import { AuthenticatedRequest } from "@repo/backend/middlewares/auth"; const getChatsController = async (req: AuthenticatedRequest, res: Response) => { @@ -10,17 +10,30 @@ const getChatsController = async (req: AuthenticatedRequest, res: Response) => { const getChatDetailsController = async ( req: AuthenticatedRequest, - res: Response, + res: Response +) => { + const { chatId } = req.params; + const chatDetails = await getChatDetails(req.userId, parseInt(chatId)); + res.status(StatusCodes.OK).json({ ...chatDetails }); +}; + +const getChatMessagesController = async ( + req: AuthenticatedRequest, + res: Response ) => { const { chatId } = req.params; const { page, limit } = req.query; - const chatDetails = await getChatDetails( + const chatMessages = await getChatMessages( req.userId, parseInt(chatId), parseInt(page as string), - parseInt(limit as string), + parseInt(limit as string) ); - res.status(StatusCodes.OK).json({ chatDetails }); + res.status(StatusCodes.OK).json({ ...chatMessages }); }; -export { getChatsController, getChatDetailsController }; +export { + getChatsController, + getChatDetailsController, + getChatMessagesController, +}; diff --git a/apps/backend/src/modules/chat/chat.route.ts b/apps/backend/src/modules/chat/chat.route.ts index 22ac2e5..7aee972 100644 --- a/apps/backend/src/modules/chat/chat.route.ts +++ b/apps/backend/src/modules/chat/chat.route.ts @@ -4,16 +4,34 @@ import validateHandler from "@repo/backend/middlewares/validation"; import { getChatsController, getChatDetailsController, + getChatMessagesController, } from "./chat.controller"; import { getChatDetailsSchema } from "./chat.schema"; const chatRouter = Router(); -chatRouter.get("/", (req, res) => { - getChatsController(req as AuthenticatedRequest, res as Response); -}); -chatRouter.post("/:id", validateHandler(getChatDetailsSchema), (req, res) => { - getChatDetailsController(req as AuthenticatedRequest, res as Response); +chatRouter.get("/", async (req, res) => { + await getChatsController(req as AuthenticatedRequest, res as Response); }); +chatRouter.get( + "/:chatId", + validateHandler(getChatDetailsSchema), + async (req, res) => { + await getChatDetailsController( + req as AuthenticatedRequest, + res as Response + ); + } +); +chatRouter.get( + "/:chatId/messages", + validateHandler(getChatDetailsSchema), + async (req, res) => { + await getChatMessagesController( + req as AuthenticatedRequest, + res as Response + ); + } +); export default chatRouter; diff --git a/apps/backend/src/modules/chat/chat.schema.ts b/apps/backend/src/modules/chat/chat.schema.ts index dde0ce8..41f64ff 100644 --- a/apps/backend/src/modules/chat/chat.schema.ts +++ b/apps/backend/src/modules/chat/chat.schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; const getChatDetailsSchema = z.object({ params: z.object({ - id: z.coerce.number().int(), + chatId: z.coerce.number().int(), }), query: z.object({ page: z.coerce.number().positive().int().default(1), diff --git a/apps/backend/src/modules/chat/chat.service.ts b/apps/backend/src/modules/chat/chat.service.ts index 52cd0e9..3d04399 100644 --- a/apps/backend/src/modules/chat/chat.service.ts +++ b/apps/backend/src/modules/chat/chat.service.ts @@ -1,116 +1,375 @@ -import { and, count, desc, eq, exists, notExists } from "drizzle-orm"; +import { + and, + count, + desc, + eq, + getTableColumns, + ne, + notExists, + sql, +} from "drizzle-orm"; import { db } from "@repo/database"; import { + blockedUsers, + type Chat, + ChatParticipant, chatParticipants, chats, messageReadReceipts, messages, + User, + users, } from "@repo/database/schema"; -import { AuthError } from "@repo/backend/errors"; +import { AuthError, NotFoundError } from "@repo/backend/errors"; + +type OtherUser = NonNullable< + Awaited> +>; + +const getDirectChatOtherParticipant = async ( + chatId: number, + userId: number +) => { + return ( + await db + .select({ + id: chatParticipants.userId, + displayName: users.displayName, + profilePicture: users.profilePicture, + }) + .from(chatParticipants) + .innerJoin(users, eq(chatParticipants.userId, users.id)) + .where( + and( + eq(chatParticipants.chatId, chatId), + ne(chatParticipants.userId, userId) + ) + ) + )[0]; +}; + +const enrichDirectChatWithUserInfo = async ( + chat: T, + otherUser: OtherUser +) => { + chat.name = otherUser.displayName; + chat.picture = otherUser.profilePicture!; + + return chat; +}; + +const enrichDirectChatWithBlockingInfo = async ( + chat: T, + userId: number, + otherUser: OtherUser +) => { + const isBlocked = await db.query.blockedUsers + .findFirst({ + where: and( + eq(blockedUsers.blockerId, userId), + eq(blockedUsers.blockedId, otherUser.id) + ), + }) + .then((block) => !!block); + + return { + ...chat, + isBlocked, + }; +}; + +const enrichDirectChat = async (chat: T, userId: number) => { + if (chat.type === "direct") { + const otherUser = await getDirectChatOtherParticipant(chat.id, userId); + if (!otherUser) return chat; + chat = await enrichDirectChatWithUserInfo(chat, otherUser); + chat = await enrichDirectChatWithBlockingInfo(chat, userId, otherUser); + } + return chat; +}; + +const anonymizeBlockedUserData = (user: User) => { + return { + id: user.id, + phoneNumber: "Hidden", + displayName: "User Unavailable", + profilePicture: null, + about: null, + status: false, + lastSeen: user.lastSeen, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +}; + +const getUserBlockers = async (userId: number) => { + const blockedRelationships = await db.query.blockedUsers.findMany({ + where: eq(blockedUsers.blockedId, userId), + }); + + return new Set(blockedRelationships.map((rel) => rel.blockerId)); +}; const getChats = async (userId: number) => { + const result = await db + .select({ + chatParticipants: { + userId: chatParticipants.userId, + role: chatParticipants.role, + isBlocked: sql`( + SELECT EXISTS ( + SELECT 1 + FROM ${blockedUsers} + WHERE ${blockedUsers.blockerId} = ${userId} + AND ${blockedUsers.blockedId} = ${chatParticipants.userId} + ) + )`.as("isBlocked"), + }, + id: chats.id, + type: chats.type, + createdAt: chats.createdAt, + displayName: sql`CASE + WHEN ${chats.type} = 'group' THEN ${chats.name} + ELSE ( + SELECT ${users.displayName} + FROM ${users} + JOIN ${chatParticipants} ON ${users.id} = ${chatParticipants.userId} + WHERE ${chatParticipants.chatId} = ${chats.id} AND ${users.id} != ${userId} + LIMIT 1 + ) + END`.as("displayName"), + displayPicture: sql`CASE + WHEN ${chats.type} = 'group' THEN ${chats.picture} + ELSE ( + SELECT ${users.profilePicture} + FROM ${users} + JOIN ${chatParticipants} ON ${users.id} = ${chatParticipants.userId} + WHERE ${chatParticipants.chatId} = ${chats.id} AND ${users.id} != ${userId} + LIMIT 1 + ) + END`.as("displayPicture"), + lastMessage: sql`( + SELECT ${messages.content} + FROM ${messages} + WHERE ${messages.chatId} = ${chats.id} + ORDER BY ${messages.createdAt} DESC + LIMIT 1 + )`.as("lastMessage"), + lastMessageTime: sql`( + SELECT ${messages.createdAt} + FROM ${messages} + WHERE ${messages.chatId} = ${chats.id} + ORDER BY ${messages.createdAt} DESC + LIMIT 1 + )`.as("lastMessageTime"), + lastMessageSender: sql`( + SELECT json_build_object( + 'userId', ${users.id}, + 'displayName', ${users.displayName} + ) + FROM ${messages} + JOIN ${users} ON ${messages.senderId} = ${users.id} + WHERE ${messages.chatId} = ${chats.id} + ORDER BY ${messages.createdAt} DESC + LIMIT 1 + )`.as("lastMessageSender"), + unreadCount: sql`( + SELECT COUNT(*) + FROM ${messages} + WHERE ${messages.chatId} = ${chats.id} + AND NOT EXISTS ( + SELECT 1 + FROM ${messageReadReceipts} + WHERE ${messageReadReceipts.chatId} = ${chats.id} + AND ${messageReadReceipts.userId} = ${userId} + ) + )`.as("unreadCount"), + isBlocked: sql`( + SELECT EXISTS ( + SELECT 1 + FROM ${blockedUsers} + WHERE ${blockedUsers.blockerId} = ${userId} + AND ${blockedUsers.blockedId} = ${chatParticipants.userId} + ) + )`.as("isBlocked"), + }) + .from(chats) + .innerJoin(chatParticipants, eq(chats.id, chatParticipants.chatId)) + .where(eq(chatParticipants.userId, userId)); + // .orderBy(desc(sql`lastMessageTime`)); + + return result; + const userChats = await db .select({ id: chats.id, + type: chats.type, + name: chats.name, + description: chats.description, + picture: chats.picture, + createdAt: chats.createdAt, + updatedAt: chats.updatedAt, }) .from(chats) .innerJoin(chatParticipants, eq(chatParticipants.chatId, chats.id)) .where(eq(chatParticipants.userId, userId)); + const blockerIds = await getUserBlockers(userId); + return await Promise.all( userChats.map(async (chat) => { + chat = await enrichDirectChat(chat, userId); + const latestMessage = await db.query.messages.findFirst({ where: eq(messages.chatId, chat.id), - orderBy: [desc(messages.sentAt)], + orderBy: [desc(messages.createdAt)], with: { - sender: { - columns: { - id: false, - }, - }, + sender: true, }, }); - const unreadMessagesCount = await db - .select({ count: count() }) - .from(messages) - .where( - notExists( - db - .select() - .from(messageReadReceipts) - .where( - and( - eq(messageReadReceipts.chatId, chat.id), - eq(messageReadReceipts.userId, userId), - ), - ), - ), - ); + let processedLatestMessage = latestMessage; + if (latestMessage && blockerIds.has(latestMessage.sender.id)) { + processedLatestMessage = { + ...latestMessage, + sender: anonymizeBlockedUserData(latestMessage.sender), + }; + } + + const unreadMessagesCount = ( + await db + .select({ count: count() }) + .from(messages) + .where( + notExists( + db + .select() + .from(messageReadReceipts) + .where( + and( + eq(messageReadReceipts.chatId, chat.id), + eq(messageReadReceipts.userId, userId) + ) + ) + ) + ) + )[0].count; return { ...chat, - latestMessage, + latestMessage: processedLatestMessage, unreadMessagesCount, }; - }), + }) ); }; -const getChatDetails = async ( - userId: number, - chatId: number, - page: number, - limit: number, -) => { +const getChatDetails = async (userId: number, chatId: number) => { const isParticipant = await db.query.chatParticipants.findFirst({ where: and( eq(chatParticipants.userId, userId), - eq(chatParticipants.chatId, chatId), + eq(chatParticipants.chatId, chatId) ), }); if (!isParticipant) throw new AuthError("You are not a participant of this chat"); - const chat = await db.query.chats.findFirst({ + let chat = await db.query.chats.findFirst({ where: eq(chats.id, chatId), with: { chatParticipants: { with: { - user: { - columns: { - id: false, - }, - }, + user: true, }, }, }, }); + if (!chat) throw new NotFoundError("Chat not found."); + + const blockerIds = await getUserBlockers(userId); + + chat.chatParticipants = chat.chatParticipants.map((participant) => { + if (blockerIds.has(participant.user.id)) { + return { + ...participant, + user: anonymizeBlockedUserData(participant.user), + }; + } + return participant; + }); + chat = await enrichDirectChat(chat, userId); + + return chat; +}; + +const getChatMessages = async ( + userId: number, + chatId: number, + page: number, + limit: number +) => { + const isParticipant = await db.query.chatParticipants.findFirst({ + where: and( + eq(chatParticipants.userId, userId), + eq(chatParticipants.chatId, chatId) + ), + }); + if (!isParticipant) + throw new AuthError("You are not a participant of this chat"); + + const chat = await db.query.chats.findFirst({ + where: eq(chats.id, chatId), + }); + if (!chat) throw new NotFoundError("Chat not found."); + + const totalMessages = await db.query.messages.findMany({ + where: eq(messages.chatId, chatId), + }); + + const totalPages = Math.ceil(totalMessages.length / limit); const messagesOffset = (page - 1) * limit; + const hasPrev = page > 1; + const hasNext = page < totalPages; + const prevPage = hasPrev ? page - 1 : null; + const nextPage = hasNext ? page + 1 : null; const chatMessages = await db.query.messages.findMany({ where: eq(messages.chatId, chatId), limit: limit, offset: messagesOffset, + orderBy: [desc(messages.createdAt)], with: { - sender: { - columns: { - id: false, - }, - }, + sender: true, + readReceipnts: true, + attachments: true, }, }); + const blockerIds = await getUserBlockers(userId); + + const processedMessages = chatMessages.map((message) => { + if (blockerIds.has(message.senderId)) { + return { + ...message, + sender: anonymizeBlockedUserData(message.sender), + }; + } + return message; + }); + return { - ...chat, - messages: chatMessages, + data: processedMessages, pagination: { page, limit, - hasMore: chatMessages.length === limit, + totalPages, + prevPage, + nextPage, + hasNext, + hasPrev, }, }; }; -export { getChats, getChatDetails }; +export { getChats, getChatDetails, getChatMessages }; From 398cf588a35e6aa40560586c290eb5fb66958bf9 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Wed, 4 Jun 2025 18:24:45 +0300 Subject: [PATCH 12/28] feat: add typiclient and typiserver --- packages/typiclient/eslint.config.js | 4 + packages/typiclient/package.json | 42 +++ packages/typiclient/src/client.ts | 197 ++++++++++++++ packages/typiclient/src/index.ts | 2 + packages/typiclient/src/types.ts | 166 ++++++++++++ packages/typiclient/tsconfig.json | 12 + packages/typiclient/turbo.json | 8 + packages/typiserver/eslint.config.js | 4 + packages/typiserver/package.json | 54 ++++ packages/typiserver/src/http.ts | 130 ++++++++++ packages/typiserver/src/index.ts | 2 + packages/typiserver/src/server.ts | 367 +++++++++++++++++++++++++++ packages/typiserver/src/types.ts | 148 +++++++++++ packages/typiserver/tsconfig.json | 12 + packages/typiserver/turbo.json | 8 + 15 files changed, 1156 insertions(+) create mode 100644 packages/typiclient/eslint.config.js create mode 100644 packages/typiclient/package.json create mode 100644 packages/typiclient/src/client.ts create mode 100644 packages/typiclient/src/index.ts create mode 100644 packages/typiclient/src/types.ts create mode 100644 packages/typiclient/tsconfig.json create mode 100644 packages/typiclient/turbo.json create mode 100644 packages/typiserver/eslint.config.js create mode 100644 packages/typiserver/package.json create mode 100644 packages/typiserver/src/http.ts create mode 100644 packages/typiserver/src/index.ts create mode 100644 packages/typiserver/src/server.ts create mode 100644 packages/typiserver/src/types.ts create mode 100644 packages/typiserver/tsconfig.json create mode 100644 packages/typiserver/turbo.json diff --git a/packages/typiclient/eslint.config.js b/packages/typiclient/eslint.config.js new file mode 100644 index 0000000..f6870b3 --- /dev/null +++ b/packages/typiclient/eslint.config.js @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/typiclient/package.json b/packages/typiclient/package.json new file mode 100644 index 0000000..d90929c --- /dev/null +++ b/packages/typiclient/package.json @@ -0,0 +1,42 @@ +{ + "name": "@repo/typiclient", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "dist/**", + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/es/index.d.ts", + "default": "./dist/es/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + } + }, + "scripts": { + "build": "bunchee", + "dev": "bunchee --watch", + "test": "jest", + "lint:types": "tsc --noEmit", + "lint:check": "eslint --max-warnings=0", + "lint:fix": "eslint --fix" + }, + "dependencies": { + "@repo/typiserver": "*", + "superjson": "^2.2.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "bunchee": "^6.5.1", + "eslint": "^8.56.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/typiclient/src/client.ts b/packages/typiclient/src/client.ts new file mode 100644 index 0000000..7fa3641 --- /dev/null +++ b/packages/typiclient/src/client.ts @@ -0,0 +1,197 @@ +import { deserialize } from "superjson"; +import type { RouteHandlerResponse, TypiRouter } from "@repo/typiserver"; +import { + type HttpMethod, + type HttpStatusCode, + getStatus, +} from "@repo/typiserver/http"; +import { + BaseHeaders, + ClientOptions, + RequestInterceptors, + TypiClientInstance, +} from "./types"; + +export class TypiClient { + private baseUrl: string; + private path: string[]; + private baseHeaders?: BaseHeaders; + private interceptors?: RequestInterceptors; + private options?: ClientOptions; + + constructor( + baseUrl: string, + path: string[], + headers?: BaseHeaders, + interceptors?: RequestInterceptors, + options?: ClientOptions + ) { + this.baseUrl = baseUrl; + this.path = path; + this.baseHeaders = headers; + this.interceptors = interceptors; + this.options = options || {}; + + return new Proxy(() => {}, { + get: (_, prop) => { + if (typeof prop === "string") { + return new TypiClient( + this.baseUrl, + [...this.path, prop], + this.baseHeaders, + this.interceptors, + this.options + ); + } + }, + apply: (_, __, [input]) => { + const method = path[path.length - 1] as HttpMethod; + const urlWithoutMethod = this.path.slice(0, -1).join("/"); + const url = `${this.baseUrl}/${urlWithoutMethod}`; + return this.executeRequest(url, method, input); + }, + }) as any; + } + + private async executeRequest(path: string, method: HttpMethod, input: any) { + const url = new URL(path); + + if (input?.path) { + Object.entries(input.path).forEach(([key, value]) => { + url.pathname = url.pathname.replace(`:${key}`, String(value)); + }); + } + + if (input?.query) { + Object.entries(input.query).forEach(([key, value]) => { + url.searchParams.set(key, String(value)); + }); + } + + let cookieHeader = null; + if (input?.cookies) + cookieHeader = Object.entries(input.cookies) + .map(([key, value]) => `${key}=${value}`) + .join("; "); + + let headers = { + "Content-Type": "application/json", + ...(input?.headers || {}), + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + }; + + if (this.baseHeaders) { + const headerEntries = await Promise.all( + Object.entries(this.baseHeaders).map( + async ([name, valueOrFunction]) => { + if (typeof valueOrFunction === "function") { + const result = valueOrFunction(); + const value = result instanceof Promise ? await result : result; + return [name, value]; + } else { + return [name, valueOrFunction]; + } + } + ) + ); + + headers = { ...headers, ...Object.fromEntries(headerEntries) }; + } + + let config: RequestInit = { + credentials: this.options?.credentials, + method: method, + headers: headers, + body: + method !== "get" && method !== "head" && input?.body + ? JSON.stringify(input.body) + : undefined, + signal: this.options?.timeout + ? AbortSignal.timeout(this.options.timeout) + : undefined, + }; + + const makeRequest = async (): Promise => { + try { + for (const requestInterceptor of this.interceptors?.onRequest || []) { + const result = await requestInterceptor({ + path: new URL(path).pathname, + config, + }); + if (isRouteHandlerResponse(result)) { + return result; + } else if (result !== undefined) { + config = result; + } + } + + let response = await fetch(url.toString(), config); + + for (const interceptor of this.interceptors?.onResponse || []) { + const result = await interceptor({ + path: new URL(path).pathname, + config, + response, + retry: makeRequest, + }); + if (isRouteHandlerResponse(result)) { + return result; + } + } + + const data = deserialize(await response.json()); + const status = response.status as HttpStatusCode; + return { + status: getStatus(status)!.key, + data: data as any, + response: response, + }; + } catch (error) { + for (const errorInterceptor of this.interceptors?.onError || []) { + const result = await errorInterceptor({ + path: new URL(path).pathname, + config, + error, + retry: makeRequest, + }); + if (isRouteHandlerResponse(result)) { + return result; + } + } + } + }; + + return makeRequest(); + } +} + +export function createTypiClient({ + baseUrl, + baseHeaders, + interceptors, + options, +}: { + baseUrl: string; + baseHeaders?: BaseHeaders; + interceptors?: RequestInterceptors; + options?: ClientOptions; +}): TypiClientInstance { + return new TypiClient( + baseUrl, + [], + baseHeaders, + interceptors, + options + ) as TypiClientInstance; +} + +const isRouteHandlerResponse = ( + result: any +): result is RouteHandlerResponse => { + return ( + result && + typeof result === "object" && + "status" in result && + "data" in result + ); +}; diff --git a/packages/typiclient/src/index.ts b/packages/typiclient/src/index.ts new file mode 100644 index 0000000..5480084 --- /dev/null +++ b/packages/typiclient/src/index.ts @@ -0,0 +1,2 @@ +export * from "./client" +export * from "./types" diff --git a/packages/typiclient/src/types.ts b/packages/typiclient/src/types.ts new file mode 100644 index 0000000..d112f9e --- /dev/null +++ b/packages/typiclient/src/types.ts @@ -0,0 +1,166 @@ +import { type TypiClient } from "./client"; +import { + MiddlewareHandlers, + RouteHandlerResponse, + type TypiRouter, +} from "@repo/typiserver"; +import { + ExtractRoutesOutputsByStatusCodes, + RouteDefinition, + RouteHandlerValidatedInput, + TypiRoute, +} from "@repo/typiserver"; +import { HttpErrorStatusKey } from "@repo/typiserver/http"; + +export type TypiClientInstance> = TypiClient & { + [Path in keyof TRouter["routes"] as StripLeadingSlash< + Path & string + >]: TRouter["routes"][Path] extends TypiRouter + ? TypiClientInstance + : TRouter["routes"][Path] extends TypiRoute + ? { + [Method in keyof TRouter["routes"][Path]]: ( + params?: InferRouteInput + ) => Promise< + InferRouteOutput & { + response: Response; + } + >; + } + : never; +}; + +export type BaseHeaders = Record< + string, + string | (() => string | Promise) +>; + +export interface RequestInterceptors { + onRequest?: (({ + path, + config, + }: { + path: string; + config: RequestInit & { + [key: string]: any; + }; + }) => MaybePromise)[]; + onResponse?: (({ + path, + config, + response, + retry, + }: { + path: string; + config: RequestInit & { + [key: string]: any; + }; + response: Response; + retry: () => Promise; + }) => MaybePromise)[]; + onError?: (({ + path, + config, + error, + retry, + }: { + path: string; + config: RequestInit & { + [key: string]: any; + }; + error: any; + retry: () => Promise; + }) => MaybePromise)[]; +} + +export interface ClientOptions { + credentials?: RequestCredentials; + timeout?: number; +} + +type InferRouteInput< + TRouter extends TypiRouter, + TPath extends keyof TRouter["routes"], + TMethod extends keyof TRouter["routes"][TPath], +> = + TRouter["routes"][TPath][TMethod] extends RouteDefinition< + any, + infer TInput, + any + > + ? TPath extends string + ? RouteHandlerValidatedInput extends infer TValidatedInput + ? TValidatedInput extends { cookies: any } + ? Omit & { + cookies?: { + [K in keyof TValidatedInput["cookies"]]?: TValidatedInput["cookies"][K]; + }; + } + : TValidatedInput + : never + : never + : never; + +// type InferRouteInput< +// TRouter extends TypiRouter, +// TPath extends keyof TRouter["routes"], +// TMethod extends keyof TRouter["routes"][TPath], +// > = +// TRouter["routes"][TPath][TMethod] extends RouteDefinition< +// any, +// infer TInput, +// any +// > +// ? TPath extends string +// ? RouteHandlerValidatedInput +// : never +// : never; + +type InferRouteOutput< + TRouter extends TypiRouter, + TPath extends keyof TRouter["routes"], + TMethod extends keyof TRouter["routes"][TPath], +> = + TRouter["routes"][TPath][TMethod] extends RouteDefinition< + any, + any, + infer TMiddlewares, + infer THandlerOutput + > + ? + | ExtractRoutesOutputsByStatusCodes + | THandlerOutput // Has actual middlewares + : never; + +export type InferRouterInputs> = { + [Path in keyof TRouter["routes"]]: TRouter["routes"][Path] extends TypiRouter + ? InferRouterInputs + : TRouter["routes"][Path] extends TypiRoute + ? { + [Method in keyof TRouter["routes"][Path]]: InferRouteInput< + TRouter, + Path, + Method + >; + } + : never; +}; + +export type InferRouterOutputs> = { + [Path in keyof TRouter["routes"]]: TRouter["routes"][Path] extends TypiRouter + ? InferRouterOutputs + : TRouter["routes"][Path] extends TypiRoute + ? { + [Method in keyof TRouter["routes"][Path]]: Extract< + InferRouteOutput, + { + status: "OK"; + } + >["data"]; + } + : never; +}; + +type StripLeadingSlash = S extends `/${infer R}` ? R : S; + +type MaybePromise = Promise | T; diff --git a/packages/typiclient/tsconfig.json b/packages/typiclient/tsconfig.json new file mode 100644 index 0000000..1c8b0ce --- /dev/null +++ b/packages/typiclient/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/typiclient/turbo.json b/packages/typiclient/turbo.json new file mode 100644 index 0000000..35be9c6 --- /dev/null +++ b/packages/typiclient/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/packages/typiserver/eslint.config.js b/packages/typiserver/eslint.config.js new file mode 100644 index 0000000..f6870b3 --- /dev/null +++ b/packages/typiserver/eslint.config.js @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/typiserver/package.json b/packages/typiserver/package.json new file mode 100644 index 0000000..7236f16 --- /dev/null +++ b/packages/typiserver/package.json @@ -0,0 +1,54 @@ +{ + "name": "@repo/typiserver", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "dist/**", + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/es/index.d.ts", + "default": "./dist/es/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + }, + "./http": { + "import": { + "types": "./dist/es/http/index.d.ts", + "default": "./dist/es/http/index.js" + }, + "require": { + "types": "./dist/cjs/http/index.d.cts", + "default": "./dist/cjs/http/index.cjs" + } + } + }, + "scripts": { + "build": "bunchee", + "dev": "bunchee --watch", + "test": "jest", + "lint:types": "tsc --noEmit", + "lint:check": "eslint --max-warnings=0", + "lint:fix": "eslint --fix" + }, + "dependencies": { + "express": "^4.18.2", + "superjson": "^2.2.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@types/express": "^4.17.21", + "@types/node": "^20.10.6", + "bunchee": "^6.5.1", + "eslint": "^8.56.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/typiserver/src/http.ts b/packages/typiserver/src/http.ts new file mode 100644 index 0000000..e232bbb --- /dev/null +++ b/packages/typiserver/src/http.ts @@ -0,0 +1,130 @@ +export type HttpMethod = + | "all" + | "get" + | "post" + | "put" + | "delete" + | "patch" + | "options" + | "head"; + +const HttpStatus = [ + { key: "CONTINUE", code: 100, label: "Continue" }, + { key: "SWITCHING_PROTOCOLS", code: 101, label: "Switching Protocols" }, + { key: "PROCESSING", code: 102, label: "Processing" }, + { key: "EARLY_HINTS", code: 103, label: "Early Hints" }, + { key: "OK", code: 200, label: "OK" }, + { key: "CREATED", code: 201, label: "Created" }, + { key: "ACCEPTED", code: 202, label: "Accepted" }, + { + key: "NON_AUTHORITATIVE_INFORMATION", + code: 203, + label: "Non-Authoritative Information", + }, + { key: "NO_CONTENT", code: 204, label: "No Content" }, + { key: "RESET_CONTENT", code: 205, label: "Reset Content" }, + { key: "PARTIAL_CONTENT", code: 206, label: "Partial Content" }, + { key: "MULTI_STATUS", code: 207, label: "Multi-Status" }, + { key: "ALREADY_REPORTED", code: 208, label: "Already Reported" }, + { key: "IM_USED", code: 226, label: "IM Used" }, + { key: "MULTIPLE_CHOICES", code: 300, label: "Multiple Choices" }, + { key: "MOVED_PERMANENTLY", code: 301, label: "Moved Permanently" }, + { key: "FOUND", code: 302, label: "Found" }, + { key: "SEE_OTHER", code: 303, label: "See Other" }, + { key: "NOT_MODIFIED", code: 304, label: "Not Modified" }, + { key: "TEMPORARY_REDIRECT", code: 307, label: "Temporary Redirect" }, + { key: "PERMANENT_REDIRECT", code: 308, label: "Permanent Redirect" }, + + { key: "BAD_REQUEST", code: 400, label: "Bad Request" }, + { key: "UNAUTHORIZED", code: 401, label: "Unauthorized" }, + { key: "PAYMENT_REQUIRED", code: 402, label: "Payment Required" }, + { key: "FORBIDDEN", code: 403, label: "Forbidden" }, + { key: "NOT_FOUND", code: 404, label: "Not Found" }, + { key: "METHOD_NOT_ALLOWED", code: 405, label: "Method Not Allowed" }, + { key: "NOT_ACCEPTABLE", code: 406, label: "Not Acceptable" }, + { + key: "PROXY_AUTHENTICATION_REQUIRED", + code: 407, + label: "Proxy Authentication Required", + }, + { key: "REQUEST_TIMEOUT", code: 408, label: "Request Timeout" }, + { key: "CONFLICT", code: 409, label: "Conflict" }, + { key: "GONE", code: 410, label: "Gone" }, + { key: "LENGTH_REQUIRED", code: 411, label: "Length Required" }, + { key: "PRECONDITION_FAILED", code: 412, label: "Precondition Failed" }, + { key: "CONTENT_TOO_LONG", code: 413, label: "Content Too Long" }, + { key: "URI_TOO_LONG", code: 414, label: "URI Too Long" }, + { key: "UNSUPPORTED_MEDIA_TYPE", code: 415, label: "Unsupported Media Type" }, + { key: "RANGE_NOT_SATISFIABLE", code: 416, label: "Range Not Satisfiable" }, + { key: "EXPECTATION_FAILED", code: 417, label: "Expectation Failed" }, + { key: "IM_A_TEAPOT", code: 418, label: "I'm a teapot" }, + { key: "MISDIRECTED_REQUEST", code: 421, label: "Misdirected Request" }, + { key: "UNPROCESSABLE_CONTENT", code: 422, label: "Unprocessable Content" }, + { key: "LOCKED", code: 423, label: "Locked" }, + { key: "FAILED_DEPENDENCY", code: 424, label: "Failed Dependency" }, + { key: "TOO_EARLY", code: 425, label: "Too Early" }, + { key: "UPGRADE_REQUIRED", code: 426, label: "Upgrade Required" }, + { key: "PRECONDITION_REQUIRED", code: 428, label: "Precondition Required" }, + { key: "TOO_MANY_REQUESTS", code: 429, label: "Too Many Requests" }, + { + key: "REQUEST_HEADER_FIELDS_TOO_LARGE", + code: 431, + label: "Request Header Fields Too Large", + }, + { + key: "UNAVAILABLE_FOR_LEGAL_REASONS", + code: 451, + label: "Unavailable For Legal Reasons", + }, + { key: "INTERNAL_SERVER_ERROR", code: 500, label: "Internal Server Error" }, + { key: "NOT_IMPLEMENTED", code: 501, label: "Not Implemented" }, + { key: "BAD_GATEWAY", code: 502, label: "Bad Gateway" }, + { key: "SERVICE_UNAVAILABLE", code: 503, label: "Service Unavailable" }, + { key: "GATEWAY_TIMEOUT", code: 504, label: "Gateway Timeout" }, + { + key: "HTTP_VERSION_NOT_SUPPORTED", + code: 505, + label: "HTTP Version Not Supported", + }, + { + key: "VARIANT_ALSO_NEGOTIATES", + code: 506, + label: "Variant Also Negotiates", + }, + { key: "INSUFFICIENT_STORAGE", code: 507, label: "Insufficient Storage" }, + { key: "LOOP_DETECTED", code: 508, label: "Loop Detected" }, + { key: "NOT_EXTENDED", code: 510, label: "Not Extended" }, + { + key: "NETWORK_AUTHENTICATION_REQUIRED", + code: 511, + label: "Network Authentication Required", + }, +] as const; + +// prettier-ignore +export type HttpSuccessStatus = Extract<(typeof HttpStatus)[number], { code: + | 100 | 101 | 102 | 103 + | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 + | 300 | 301 | 302 | 303 | 304 | 307 | 308 + }>; +export type HttpSuccessStatusKey = HttpSuccessStatus["key"]; +export type HttpSuccessStatusCode = HttpSuccessStatus["code"]; + +// prettier-ignore +export type HttpErrorStatus = Extract<(typeof HttpStatus)[number], { code: + 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | + 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | + 424 | 425 | 426 | 428 | 429 | 431 | 451 | + 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511 } +>; +export type HttpErrorStatusKey = HttpErrorStatus["key"]; +export type HttpErrorStatusCode = HttpErrorStatus["code"]; + +export type HttpStatusKey = HttpSuccessStatusKey | HttpErrorStatusKey; +export type HttpStatusCode = HttpSuccessStatusCode | HttpErrorStatusCode; + +export function getStatus(input: HttpStatusKey | HttpStatusCode) { + return HttpStatus.find( + (status) => status.key === input || status.code === input + )!; +} diff --git a/packages/typiserver/src/index.ts b/packages/typiserver/src/index.ts new file mode 100644 index 0000000..9dbbe41 --- /dev/null +++ b/packages/typiserver/src/index.ts @@ -0,0 +1,2 @@ +export * from "./server" +export * from "./types" diff --git a/packages/typiserver/src/server.ts b/packages/typiserver/src/server.ts new file mode 100644 index 0000000..e4a42c4 --- /dev/null +++ b/packages/typiserver/src/server.ts @@ -0,0 +1,367 @@ +import { Router, type Request, type Response } from "express"; +import { serialize } from "superjson"; +import { z, ZodError } from "zod"; +import { + RouteHandlerContext, + RouteHandlerResponse, + TypiRoute, + MiddlewareHandlers, + RouteDefinition, + RouteHandlerInput, + RouteHandler, + RouteMap, + Exact, + RouteHandlerErrorDataResponse, +} from "./types"; +``; +import { + HttpMethod, + HttpStatusKey, + HttpErrorStatusKey, + HttpErrorStatusCode, + getStatus, +} from "./http"; +import { error } from "console"; + +const createTypiRouter = ( + routes: TRoutes +): TypiRouter => { + return new TypiRouter(routes); +}; + +const createTypiRoute = (route: TRoute): TRoute => { + return route; +}; + +function createTypiRouteHandler< + TInput extends RouteHandlerInput, + TOutput extends RouteHandlerResponse, + TMiddlewares extends MiddlewareHandlers, +>( + RouteDefinition: RouteDefinition +): RouteDefinition; + +function createTypiRouteHandler< + TInput extends RouteHandlerInput, + TOutput extends RouteHandlerResponse, + TMiddlewares extends MiddlewareHandlers, + TPath extends string = string, +>( + path: TPath, + RouteDefinition: RouteDefinition +): RouteDefinition; + +function createTypiRouteHandler(pathOrRouteDef: any, maybeRouteDef?: any): any { + if (maybeRouteDef !== undefined) { + return maybeRouteDef; + } + return pathOrRouteDef; +} + +class TypiRouter { + public router: Router; + public routes: TRoutes; + + constructor(routes: TRoutes) { + this.router = Router(); + this.routes = routes; + + Object.entries(this.routes).forEach(([path, route]) => { + if (route instanceof TypiRouter) { + this.use(path, route); + } else { + Object.entries(route).forEach(([method, config]) => { + this.registerMethod( + method as HttpMethod, + path, + config.input as RouteHandlerInput, + config.handler as RouteHandler< + string, + RouteHandlerInput, + MiddlewareHandlers, + RouteHandlerResponse + >, + config.middlewares as MiddlewareHandlers + ); + }); + } + }); + } + + private sendResponse( + res: Response, + status: HttpStatusKey, + data: Record = {} + ) { + return res.status(getStatus(status).code).send(serialize(data)); + } + + private registerMethod< + TPath extends string, + TInput extends RouteHandlerInput, + TMiddlewaresHandlers extends MiddlewareHandlers, + TOutput extends RouteHandlerResponse, + >( + method: HttpMethod, + path: string, + input: Exact, + handler: RouteHandler, + middlewares?: TMiddlewaresHandlers + ): TypiRouter< + TRoutes & { + [key in TPath]: { + [key in typeof method]: RouteDefinition< + TPath, + TInput, + TMiddlewaresHandlers, + TOutput + >; + }; + } + > { + const makeZodSchemaFromPath = (path: string) => { + const pathParts = path.split("/").filter((part) => part !== ""); + const pathSchema: Record = {}; + pathParts.forEach((part) => { + if (part.startsWith(":")) { + const paramName = part.slice(1); + pathSchema[paramName] = z.string(); + } + }); + return z.object(pathSchema); + }; + + const handlerWrapper = async (req: Request, res: Response) => { + const parsedInput: Record = {}; + const inputsToParse: { + key: keyof RouteHandlerInput | "path"; + source: any; + schema: z.ZodTypeAny | undefined; + }[] = [ + { + key: "headers", + source: req.headers, + schema: input?.headers, + }, + { key: "body", source: req.body, schema: input?.body }, + { + key: "path", + source: req.params, + schema: makeZodSchemaFromPath(path), + }, + { key: "query", source: req.query, schema: input?.query }, + { + key: "cookies", + source: req.cookies, + schema: input?.cookies, + }, + ]; + + for (const { key, source, schema } of inputsToParse) { + if (schema) { + const result = schema.safeParse(source); + if (!result.success) { + console.error(result.error); + return this.sendResponse(res, "BAD_REQUEST", { + error: { + key: "BAD_REQUEST" as HttpErrorStatusKey, + code: getStatus("BAD_REQUEST").code, + label: getStatus("BAD_REQUEST").label, + message: + error instanceof ZodError ? error.message : `Invalid ${key}`, + }, + }); + } + parsedInput[key] = result.data; + } + } + + const baseCtx: RouteHandlerContext = { + input: parsedInput as any, + data: {} as any, + request: req, + response: res, + success: (>( + data?: TData + ): RouteHandlerResponse<"OK", TData extends undefined ? {} : TData> => { + return { + status: "OK", + data: (data ?? {}) as TData extends undefined ? {} : TData, + }; + }) as { + (): RouteHandlerResponse<"OK", {}>; + >( + data: TData + ): RouteHandlerResponse<"OK", TData>; + }, + error: ( + key: TErrorKey, + message?: string + ): RouteHandlerResponse => { + return { + status: key, + data: { + error: { + key: key, + code: getStatus(key).code as HttpErrorStatusCode, + label: getStatus(key).label, + message: message ?? "An unexpected error occurred.", + }, + }, + }; + }, + }; + + let middlewareData = {}; + + for (const middleware of middlewares ?? []) { + const middlewareCtx: RouteHandlerContext = { + ...baseCtx, + data: { ...middlewareData } as any, + }; + try { + const result = await middleware(middlewareCtx); + + if (result.status !== "OK") { + return this.sendResponse(res, result.status, { + error: { + key: result.status as HttpErrorStatusKey, + code: result.data.error.code, + label: result.data.error.label, + message: + result.data.error.message || "An unexpected error occurred", + }, + }); + } else { + if (result.data !== null) { + middlewareData = { ...middlewareData, ...result.data }; + } + } + } catch (error: unknown) { + console.error(error); + return this.sendResponse(res, "INTERNAL_SERVER_ERROR", { + error: { + key: "INTERNAL_SERVER_ERROR" as HttpErrorStatusKey, + code: getStatus("INTERNAL_SERVER_ERROR").code, + label: getStatus("INTERNAL_SERVER_ERROR").label, + message: + error instanceof Error + ? error.message + : "An unexpected error occurred", + }, + }); + } + } + + const finalCtx = { + ...baseCtx, + data: { ...middlewareData } as any, + }; + + try { + const result = await handler(finalCtx as any); + return this.sendResponse(res, result.status, result.data); + } catch (error: unknown) { + console.error(error); + + return this.sendResponse(res, "INTERNAL_SERVER_ERROR", { + error: { + key: "INTERNAL_SERVER_ERROR" as HttpErrorStatusKey, + code: getStatus("INTERNAL_SERVER_ERROR").code, + label: getStatus("INTERNAL_SERVER_ERROR").label, + message: + error instanceof Error + ? error.message + : "An unexpected error occurred", + }, + }); + } + }; + this.router[method as HttpMethod](path, handlerWrapper); + return this as any; + } + + private createHttpMethod(method: TMethod) { + return < + TPath extends string, + TInput extends RouteHandlerInput, + TMiddlewaresHandlers extends MiddlewareHandlers, + TOutput extends RouteHandlerResponse, + >( + path: TPath, + input: Exact, + middlewaresOrHandler: + | TMiddlewaresHandlers + | RouteHandler, + handlerOrNothing?: RouteHandler< + TPath, + TInput, + TMiddlewaresHandlers, + TOutput + > + ): TypiRouter< + TRoutes & { + [key in TPath]: { + [key in TMethod]: RouteDefinition< + TPath, + TInput, + TMiddlewaresHandlers, + any + >; + }; + } + > => { + if (handlerOrNothing) { + return this.registerMethod( + method, + path, + input, + handlerOrNothing, + middlewaresOrHandler as TMiddlewaresHandlers + ); + } + + // If handlerOrNothing is not provided, middlewaresOrHandler is the handler + // and we provide an empty array for middlewares + return this.registerMethod( + method, + path, + input, + middlewaresOrHandler as RouteHandler< + TPath, + TInput, + TMiddlewaresHandlers, + TOutput + >, + [] as unknown as TMiddlewaresHandlers + ); + }; + } + + get = this.createHttpMethod("get"); + post = this.createHttpMethod("post"); + put = this.createHttpMethod("put"); + delete = this.createHttpMethod("delete"); + patch = this.createHttpMethod("patch"); + options = this.createHttpMethod("options"); + head = this.createHttpMethod("head"); + all = this.createHttpMethod("all"); + use( + path: TPath, + router: TypiRouter + ): TypiRouter< + TRoutes & { + [key in TPath]: TypiRouter; + } + > { + this.router.use(path, router.router); + return this as any; + } +} + +export { + type TypiRouter, + createTypiRouter, + createTypiRoute, + createTypiRouteHandler, +}; diff --git a/packages/typiserver/src/types.ts b/packages/typiserver/src/types.ts new file mode 100644 index 0000000..62c1473 --- /dev/null +++ b/packages/typiserver/src/types.ts @@ -0,0 +1,148 @@ +import { type Request, type Response } from "express"; +import z from "zod"; +import { type TypiRouter } from "./server"; + +import { + HttpMethod, + HttpStatusKey, + HttpSuccessStatusKey, + HttpErrorStatusKey, + HttpErrorStatusCode, +} from "./http"; + +export type ExtractRoutesOutputsByStatusCodes< + TRoutes extends RouteHandler[], + TStatusName extends HttpStatusKey, +> = { + [K in keyof TRoutes]: Awaited> extends infer TOutput + ? TOutput extends { + status: infer S; + } + ? S extends TStatusName + ? TOutput + : never + : never + : never; +}[number]; + +export type UnionToIntersection = ( + U extends any ? (x: U) => void : never +) extends (x: infer I) => void + ? I + : never; + +export type Exact = T extends TShape + ? Exclude extends never + ? T + : never + : never; + +export type RouteHandlerInput = { + headers?: z.ZodObject>; + body?: z.ZodObject>; + query?: z.ZodObject>; + cookies?: z.ZodObject>; +}; + +export type ExtractPathParams = + TPath extends `${string}/:${infer Param}/${infer Rest}` + ? { [K in Param]: string } & ExtractPathParams<`/${Rest}`> + : TPath extends `${string}/:${infer Param}` + ? { [K in Param]: string } + : {}; + +export type RouteHandlerValidatedInput< + TInput extends RouteHandlerInput, + TPath extends string, +> = { + [K in keyof TInput as TInput[K] extends undefined + ? never + : K]: TInput[K] extends z.ZodType ? z.infer : undefined; +} & ([keyof ExtractPathParams] extends [never] + ? {} // no path params, don't add path + : { path: ExtractPathParams }); + +export type RouteHandlerContext< + TPath extends string = string, + TInput extends RouteHandlerInput = RouteHandlerInput, + TMiddlewares extends MiddlewareHandlers = never, +> = { + input: RouteHandlerValidatedInput; + data: UnionToIntersection< + ExtractRoutesOutputsByStatusCodes< + TMiddlewares, + HttpSuccessStatusKey + >["data"] + >; + request: Request; + response: Response; + success: { + (): RouteHandlerResponse<"OK", {}>; + >( + data: TData + ): RouteHandlerResponse<"OK", TData>; + }; + error: ( + key: TErrorKey, + message?: string + ) => RouteHandlerResponse; +}; + +export interface RouteHandlerErrorDataResponse { + error: { + key: HttpErrorStatusKey; + code: HttpErrorStatusCode; + label: string; + message: string; + }; +} + +type Serialize = T extends Date + ? string + : T extends (infer U)[] + ? Serialize[] + : T extends Record + ? { [K in keyof T]: Serialize } + : T; + +export type RouteHandlerResponse< + TStatusKey extends HttpStatusKey = HttpStatusKey, + TData = TStatusKey extends HttpErrorStatusKey + ? RouteHandlerErrorDataResponse + : TStatusKey extends HttpSuccessStatusKey + ? Record + : never, +> = { + status: TStatusKey; + data: TData; +}; + +export type MiddlewareHandlers = RouteHandler[]; + +export type RouteHandler< + TPath extends string = string, + TInput extends RouteHandlerInput = RouteHandlerInput, + TMiddlewares extends MiddlewareHandlers = never, + TOutput extends RouteHandlerResponse = RouteHandlerResponse, +> = ( + ctx: RouteHandlerContext +) => TOutput | Promise; + +export type RouteDefinition< + TPath extends string = string, + TInput extends RouteHandlerInput = RouteHandlerInput, + TMiddlewares extends MiddlewareHandlers = never, + TOutput extends RouteHandlerResponse = RouteHandlerResponse, +> = { + middlewares?: TMiddlewares; + input?: TInput; + handler: RouteHandler; +}; + +export type TypiRoute = { + [Method in HttpMethod]?: RouteDefinition; +}; + +export type RouteMap = { + [TPath in string]: TypiRoute | TypiRouter; +}; diff --git a/packages/typiserver/tsconfig.json b/packages/typiserver/tsconfig.json new file mode 100644 index 0000000..1c8b0ce --- /dev/null +++ b/packages/typiserver/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/typiserver/turbo.json b/packages/typiserver/turbo.json new file mode 100644 index 0000000..35be9c6 --- /dev/null +++ b/packages/typiserver/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} From 0e90772bf0013df5053eaac22167e2486380bfcf Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Fri, 6 Jun 2025 09:58:18 +0300 Subject: [PATCH 13/28] refactor: improve typistack code quality --- packages/typiclient/src/client.ts | 177 ++++++++++----- packages/typiclient/src/types.ts | 6 +- packages/typiserver/src/server.ts | 361 ++++++++++++++++-------------- packages/typiserver/src/types.ts | 1 - 4 files changed, 320 insertions(+), 225 deletions(-) diff --git a/packages/typiclient/src/client.ts b/packages/typiclient/src/client.ts index 7fa3641..36e711b 100644 --- a/packages/typiclient/src/client.ts +++ b/packages/typiclient/src/client.ts @@ -53,7 +53,7 @@ export class TypiClient { }) as any; } - private async executeRequest(path: string, method: HttpMethod, input: any) { + private buildUrl(path: string, input: any) { const url = new URL(path); if (input?.path) { @@ -68,11 +68,19 @@ export class TypiClient { }); } - let cookieHeader = null; - if (input?.cookies) - cookieHeader = Object.entries(input.cookies) - .map(([key, value]) => `${key}=${value}`) - .join("; "); + return url; + } + + private buildCookieHeader(cookies: Record) { + if (!cookies) return null; + + return Object.entries(cookies) + .map(([key, value]) => `${key}=${value}`) + .join("; "); + } + + private async buildHeaders(input: any) { + const cookieHeader = this.buildCookieHeader(input?.cookies); let headers = { "Content-Type": "application/json", @@ -98,7 +106,13 @@ export class TypiClient { headers = { ...headers, ...Object.fromEntries(headerEntries) }; } - let config: RequestInit = { + return headers; + } + + private async buildRequestConfig(method: HttpMethod, input: any) { + const headers = await this.buildHeaders(input); + + return { credentials: this.options?.credentials, method: method, headers: headers, @@ -109,59 +123,118 @@ export class TypiClient { signal: this.options?.timeout ? AbortSignal.timeout(this.options.timeout) : undefined, - }; + } as RequestInit; + } - const makeRequest = async (): Promise => { - try { - for (const requestInterceptor of this.interceptors?.onRequest || []) { - const result = await requestInterceptor({ - path: new URL(path).pathname, - config, - }); - if (isRouteHandlerResponse(result)) { - return result; - } else if (result !== undefined) { - config = result; - } + private async executeInterceptors( + url: URL, + config: RequestInit, + response?: Response, + error?: any + ) { + // Handle request interceptors + if (!response && !error) { + for (const requestInterceptor of this.interceptors?.onRequest || []) { + const result = await requestInterceptor({ path: url.pathname, config }); + if (isRouteHandlerResponse(result)) { + return { result }; + } else if (result !== undefined) { + config = result; } + } + return { config }; + } - let response = await fetch(url.toString(), config); - - for (const interceptor of this.interceptors?.onResponse || []) { - const result = await interceptor({ - path: new URL(path).pathname, - config, - response, - retry: makeRequest, - }); - if (isRouteHandlerResponse(result)) { - return result; - } + // Handle response interceptors + if (response && !error) { + for (const interceptor of this.interceptors?.onResponse || []) { + const result = await interceptor({ + path: url.pathname, + config, + response, + retry: () => this.makeRequest(url, config), + }); + if (isRouteHandlerResponse(result)) { + return { result }; } + } + } - const data = deserialize(await response.json()); - const status = response.status as HttpStatusCode; - return { - status: getStatus(status)!.key, - data: data as any, - response: response, - }; - } catch (error) { - for (const errorInterceptor of this.interceptors?.onError || []) { - const result = await errorInterceptor({ - path: new URL(path).pathname, - config, - error, - retry: makeRequest, - }); - if (isRouteHandlerResponse(result)) { - return result; - } + // Handle error interceptors + if (error) { + for (const errorInterceptor of this.interceptors?.onError || []) { + const result = await errorInterceptor({ + path: url.pathname, + config, + error, + retry: () => this.makeRequest(url, config), + }); + if (isRouteHandlerResponse(result)) { + return { result }; } } - }; + } + + return {}; + } + + private async makeRequest(url: URL, config: RequestInit): Promise { + try { + const response = await fetch(url.toString(), config); + + const interceptorResult = await this.executeInterceptors( + url, + config, + response + ); + if (interceptorResult.result) { + return interceptorResult.result; + } + + const data = deserialize(await response.json()); + const status = getStatus(response.status as HttpStatusCode).key; + + console[status === "OK" ? "log" : "error"]( + `Request to ${url.toString()} returned status ${status}`, + data + ); + + return { + status: status, + data: data as any, + response: response, + }; + } catch (error) { + console.error(`Error making request to ${url.toString()}`, error); + const interceptorResult = await this.executeInterceptors( + url, + config, + undefined, + error + ); + if (interceptorResult.result) { + return interceptorResult.result; + } + throw error; + } + } + + private async executeRequest(path: string, method: HttpMethod, input: any) { + const url = this.buildUrl(path, input); + let config = await this.buildRequestConfig(method, input); + + // Execute request interceptors + const interceptorResult = await this.executeInterceptors(url, config); + + if (interceptorResult.result) { + return interceptorResult.result; + } + + if (interceptorResult.config) { + config = interceptorResult.config; + } - return makeRequest(); + return this.makeRequest(url, config); } } diff --git a/packages/typiclient/src/types.ts b/packages/typiclient/src/types.ts index d112f9e..7308580 100644 --- a/packages/typiclient/src/types.ts +++ b/packages/typiclient/src/types.ts @@ -1,9 +1,5 @@ import { type TypiClient } from "./client"; -import { - MiddlewareHandlers, - RouteHandlerResponse, - type TypiRouter, -} from "@repo/typiserver"; +import { RouteHandlerResponse, type TypiRouter } from "@repo/typiserver"; import { ExtractRoutesOutputsByStatusCodes, RouteDefinition, diff --git a/packages/typiserver/src/server.ts b/packages/typiserver/src/server.ts index e4a42c4..85d912e 100644 --- a/packages/typiserver/src/server.ts +++ b/packages/typiserver/src/server.ts @@ -13,15 +13,12 @@ import { Exact, RouteHandlerErrorDataResponse, } from "./types"; -``; import { HttpMethod, - HttpStatusKey, HttpErrorStatusKey, HttpErrorStatusCode, getStatus, } from "./http"; -import { error } from "console"; const createTypiRouter = ( routes: TRoutes @@ -88,12 +85,199 @@ class TypiRouter { }); } - private sendResponse( + private sendResponse(res: Response, result: RouteHandlerResponse) { + return res + .status(getStatus(result.status).code) + .send(serialize(result.data)); + } + + private makeZodSchemaFromPath(path: string) { + const pathParts = path.split("/").filter((part) => part !== ""); + const pathSchema: Record = {}; + pathParts.forEach((part) => { + if (part.startsWith(":")) { + const paramName = part.slice(1); + pathSchema[paramName] = z.string(); + } + }); + return z.object(pathSchema); + } + + private createBaseContext(req: Request, res: Response): RouteHandlerContext { + return { + input: {} as any, + data: {} as any, + request: req, + response: res, + success: (>( + data?: TData + ): RouteHandlerResponse<"OK", TData extends undefined ? {} : TData> => { + const successData = { + status: "OK", + data: (data ?? {}) as TData extends undefined ? {} : TData, + }; + // console.log(successData); + return successData as any; + }) as { + (): RouteHandlerResponse<"OK", {}>; + >( + data: TData + ): RouteHandlerResponse<"OK", TData>; + }, + error: ( + key: TErrorKey, + message?: string + ): RouteHandlerResponse => { + const errorData = { + status: key, + data: { + error: { + key: key, + code: getStatus(key).code as HttpErrorStatusCode, + label: getStatus(key).label, + message: message ?? "An unexpected error occurred.", + }, + }, + }; + console.error(errorData); + return errorData; + }, + }; + } + + private parseInputs( + req: Request, + ctx: RouteHandlerContext, + path: string, + input: Exact + ) { + const parsedInput: Record = {}; + const inputsToParse: { + key: keyof RouteHandlerInput | "path"; + source: any; + schema: z.ZodTypeAny | undefined; + }[] = [ + { key: "headers", source: req.headers, schema: input?.headers }, + { key: "body", source: req.body, schema: input?.body }, + { + key: "path", + source: req.params, + schema: this.makeZodSchemaFromPath(path), + }, + { key: "query", source: req.query, schema: input?.query }, + { key: "cookies", source: req.cookies, schema: input?.cookies }, + ]; + + for (const { key, source, schema } of inputsToParse) { + if (schema) { + const result = schema.safeParse(source); + if (!result.success) { + return { + error: ctx.error( + "BAD_REQUEST", + result.error instanceof ZodError + ? result.error.message + : `Invalid ${key}` + ), + }; + } + parsedInput[key] = result.data; + } + } + + return { parsedInput }; + } + + private async executeMiddlewares< + TMiddlewaresHandlers extends MiddlewareHandlers, + >(baseCtx: RouteHandlerContext, middlewares?: TMiddlewaresHandlers) { + let middlewareData: Record = {}; + + for (const middleware of middlewares ?? []) { + const middlewareCtx: RouteHandlerContext = { + ...baseCtx, + data: { ...middlewareData } as any, + }; + try { + const result = await middleware(middlewareCtx); + + if (result.status !== "OK") { + return { + error: middlewareCtx.error( + result.status as HttpErrorStatusKey, + result.data.error.message || "An unexpected error occurred" + ), + }; + } else { + if (result.data !== null) { + middlewareData = { ...middlewareData, ...result.data }; + } + } + } catch (error: unknown) { + return { + error: baseCtx.error( + "INTERNAL_SERVER_ERROR", + error instanceof Error + ? error.message + : "An unexpected error occurred" + ), + }; + } + } + + return { middlewareData }; + } + + private async routeHandler( + req: Request, res: Response, - status: HttpStatusKey, - data: Record = {} + path: string, + input: RouteHandlerInput, + handler: RouteHandler, + middlewares?: MiddlewareHandlers ) { - return res.status(getStatus(status).code).send(serialize(data)); + // Create base context + const baseCtx = this.createBaseContext(req, res); + + // Parse inputs + const inputResult = this.parseInputs(req, baseCtx, path, input); + if (inputResult.error) return this.sendResponse(res, inputResult.error); + + // Update context with parsed input + const ctxWithParsedInput = { + ...baseCtx, + input: inputResult.parsedInput, + }; + + // Execute middlewares + const middlewareResult = await this.executeMiddlewares( + ctxWithParsedInput, + middlewares + ); + if (middlewareResult.error) + return this.sendResponse(res, middlewareResult.error); + + // Create final context and execute handler + const finalCtx = { + ...ctxWithParsedInput, + data: { ...middlewareResult.middlewareData } as any, + }; + + // Execute the handler with the final context + try { + const result = await handler(finalCtx as any); + return this.sendResponse(res, result); + } catch (error: unknown) { + return this.sendResponse( + res, + finalCtx.error( + "INTERNAL_SERVER_ERROR", + error instanceof Error + ? error.message + : "An unexpected error occurred" + ) + ); + } } private registerMethod< @@ -119,165 +303,9 @@ class TypiRouter { }; } > { - const makeZodSchemaFromPath = (path: string) => { - const pathParts = path.split("/").filter((part) => part !== ""); - const pathSchema: Record = {}; - pathParts.forEach((part) => { - if (part.startsWith(":")) { - const paramName = part.slice(1); - pathSchema[paramName] = z.string(); - } - }); - return z.object(pathSchema); - }; - - const handlerWrapper = async (req: Request, res: Response) => { - const parsedInput: Record = {}; - const inputsToParse: { - key: keyof RouteHandlerInput | "path"; - source: any; - schema: z.ZodTypeAny | undefined; - }[] = [ - { - key: "headers", - source: req.headers, - schema: input?.headers, - }, - { key: "body", source: req.body, schema: input?.body }, - { - key: "path", - source: req.params, - schema: makeZodSchemaFromPath(path), - }, - { key: "query", source: req.query, schema: input?.query }, - { - key: "cookies", - source: req.cookies, - schema: input?.cookies, - }, - ]; - - for (const { key, source, schema } of inputsToParse) { - if (schema) { - const result = schema.safeParse(source); - if (!result.success) { - console.error(result.error); - return this.sendResponse(res, "BAD_REQUEST", { - error: { - key: "BAD_REQUEST" as HttpErrorStatusKey, - code: getStatus("BAD_REQUEST").code, - label: getStatus("BAD_REQUEST").label, - message: - error instanceof ZodError ? error.message : `Invalid ${key}`, - }, - }); - } - parsedInput[key] = result.data; - } - } - - const baseCtx: RouteHandlerContext = { - input: parsedInput as any, - data: {} as any, - request: req, - response: res, - success: (>( - data?: TData - ): RouteHandlerResponse<"OK", TData extends undefined ? {} : TData> => { - return { - status: "OK", - data: (data ?? {}) as TData extends undefined ? {} : TData, - }; - }) as { - (): RouteHandlerResponse<"OK", {}>; - >( - data: TData - ): RouteHandlerResponse<"OK", TData>; - }, - error: ( - key: TErrorKey, - message?: string - ): RouteHandlerResponse => { - return { - status: key, - data: { - error: { - key: key, - code: getStatus(key).code as HttpErrorStatusCode, - label: getStatus(key).label, - message: message ?? "An unexpected error occurred.", - }, - }, - }; - }, - }; - - let middlewareData = {}; - - for (const middleware of middlewares ?? []) { - const middlewareCtx: RouteHandlerContext = { - ...baseCtx, - data: { ...middlewareData } as any, - }; - try { - const result = await middleware(middlewareCtx); - - if (result.status !== "OK") { - return this.sendResponse(res, result.status, { - error: { - key: result.status as HttpErrorStatusKey, - code: result.data.error.code, - label: result.data.error.label, - message: - result.data.error.message || "An unexpected error occurred", - }, - }); - } else { - if (result.data !== null) { - middlewareData = { ...middlewareData, ...result.data }; - } - } - } catch (error: unknown) { - console.error(error); - return this.sendResponse(res, "INTERNAL_SERVER_ERROR", { - error: { - key: "INTERNAL_SERVER_ERROR" as HttpErrorStatusKey, - code: getStatus("INTERNAL_SERVER_ERROR").code, - label: getStatus("INTERNAL_SERVER_ERROR").label, - message: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }, - }); - } - } - - const finalCtx = { - ...baseCtx, - data: { ...middlewareData } as any, - }; - - try { - const result = await handler(finalCtx as any); - return this.sendResponse(res, result.status, result.data); - } catch (error: unknown) { - console.error(error); - - return this.sendResponse(res, "INTERNAL_SERVER_ERROR", { - error: { - key: "INTERNAL_SERVER_ERROR" as HttpErrorStatusKey, - code: getStatus("INTERNAL_SERVER_ERROR").code, - label: getStatus("INTERNAL_SERVER_ERROR").label, - message: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }, - }); - } - }; - this.router[method as HttpMethod](path, handlerWrapper); + this.router[method as HttpMethod](path, (req: Request, res: Response) => + this.routeHandler(req, res, path, input, handler, middlewares) + ); return this as any; } @@ -337,7 +365,6 @@ class TypiRouter { ); }; } - get = this.createHttpMethod("get"); post = this.createHttpMethod("post"); put = this.createHttpMethod("put"); diff --git a/packages/typiserver/src/types.ts b/packages/typiserver/src/types.ts index 62c1473..ce09c10 100644 --- a/packages/typiserver/src/types.ts +++ b/packages/typiserver/src/types.ts @@ -1,7 +1,6 @@ import { type Request, type Response } from "express"; import z from "zod"; import { type TypiRouter } from "./server"; - import { HttpMethod, HttpStatusKey, From 33e42637d98f249c5d28e781ac4296375b33dc39 Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Fri, 6 Jun 2025 09:58:35 +0300 Subject: [PATCH 14/28] feat: add shadcn components --- packages/ui/package.json | 14 +- packages/ui/src/components/badge.tsx | 46 ++++ packages/ui/src/components/button.tsx | 4 +- packages/ui/src/components/dialog.tsx | 143 +++++++++++ packages/ui/src/components/drawer.tsx | 132 ++++++++++ packages/ui/src/components/dropdown-menu.tsx | 257 +++++++++++++++++++ packages/ui/src/components/form.tsx | 168 ++++++++++++ packages/ui/src/components/label.tsx | 2 +- packages/ui/src/components/scroll-area.tsx | 75 ++++++ packages/ui/src/components/sonner.tsx | 29 +++ packages/ui/src/components/tabs.tsx | 66 +++++ 11 files changed, 930 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/badge.tsx create mode 100644 packages/ui/src/components/dialog.tsx create mode 100644 packages/ui/src/components/drawer.tsx create mode 100644 packages/ui/src/components/dropdown-menu.tsx create mode 100644 packages/ui/src/components/form.tsx create mode 100644 packages/ui/src/components/scroll-area.tsx create mode 100644 packages/ui/src/components/sonner.tsx create mode 100644 packages/ui/src/components/tabs.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index 134bf3b..03119f3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -24,11 +24,15 @@ "react-dom": ">=18" }, "dependencies": { + "@hookform/resolvers": "^4.1.3", "@radix-ui/react-avatar": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/postcss": "^4.0.8", "class-variance-authority": "^0.7.1", @@ -38,9 +42,13 @@ "lucide-react": "^0.475.0", "next-themes": "^0.4.4", "postcss": "^8.5.3", + "react-hook-form": "^7.54.2", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.8", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zod": "^3.24.2" }, "devDependencies": { "@repo/eslint-config": "*", diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx new file mode 100644 index 0000000..006d0f4 --- /dev/null +++ b/packages/ui/src/components/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@repo/ui/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 5582d89..c7936fa 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@repo/ui/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { @@ -22,7 +22,7 @@ const buttonVariants = cva( }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx new file mode 100644 index 0000000..940d2c0 --- /dev/null +++ b/packages/ui/src/components/dialog.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@repo/ui/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/packages/ui/src/components/drawer.tsx b/packages/ui/src/components/drawer.tsx new file mode 100644 index 0000000..e3b2c13 --- /dev/null +++ b/packages/ui/src/components/drawer.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@repo/ui/lib/utils" + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx new file mode 100644 index 0000000..9e02935 --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@repo/ui/lib/utils"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx new file mode 100644 index 0000000..af26ad4 --- /dev/null +++ b/packages/ui/src/components/form.tsx @@ -0,0 +1,168 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; + +import { cn } from "@repo/ui/lib/utils"; +import { Label } from "@repo/ui/components/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId(); + + return ( + +
+ + ); +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField(); + + return ( +