Skip to content

Commit

Permalink
refactor clean up
Browse files Browse the repository at this point in the history
  • Loading branch information
0-don committed Jul 5, 2024
1 parent cb51423 commit f4137a1
Show file tree
Hide file tree
Showing 26 changed files with 241 additions and 116 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
SECRET=km12poik3mokpaxnjcojsandfoj1n
SECRET=km12poik3mokpaxnjcojsandfoj1nbjt
DATABASE_URL="file:../demo.db"
NEXT_PUBLIC_URL=http://localhost:3000
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
## NEP Stack

#### Next, Elysia, Prisma
### Next, Elysia, Prisma

#### with fully built auth

## Built With

- [nextjs](https://nextjs.org/) frontend & backend
- [elysia](https://elysiajs.com/) backend api wrapper
- [prisma](https://www.prisma.io/) database orm
- [tanstack-query](https://tanstack.com/query/latest) request & cache management
- [typebox](https://github.com/sinclairzx81/typebox) runtime type-safe json schema
- [jose](https://github.com/panva/jose) json web token

First, insall packages

```bash
npm run install
npm install
```

Second, init prisma and create database
Expand Down
21 changes: 11 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,25 @@
},
"dependencies": {
"@elysiajs/eden": "^1.0.14",
"@prisma/client": "^5.14.0",
"@tanstack/react-query": "^5.40.0",
"next": "14.2.3",
"@prisma/client": "^5.16.1",
"@tanstack/react-query": "^5.49.2",
"jose": "^5.6.3",
"next": "14.2.4",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/bun": "^1.1.3",
"@types/node": "^20.12.13",
"@types/bun": "^1.1.6",
"@types/node": "^20.14.9",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-next": "14.2.4",
"postcss": "^8",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"prisma": "^5.14.0",
"tailwindcss": "^3.4.3",
"prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"prisma": "^5.16.1",
"tailwindcss": "^3.4.4",
"typescript": "^5"
}
}
5 changes: 4 additions & 1 deletion src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import getQueryClient from "@/lib/query";
import getQueryClient from "@/lib/react-query";
import { rpc } from "@/lib/rpc";
import { serverUrl, setCookies } from "@/utils/server";
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
Expand All @@ -9,10 +9,13 @@ interface AuthLayoutProps {
}

export default async function AuthLayout(props: AuthLayoutProps) {
// Get the QueryClient server component instance
const queryClient = getQueryClient();

// Fetch current user data set cookies are required else they will be empty
const { data: me, error: meError } = await rpc.api.user.me.get(setCookies());

// serverUrl is a custom function because nextjs doesnt provide a way to read current url in server components
if (!meError && !serverUrl()?.includes("logout")) redirect("/dashboard");

return (
Expand Down
1 change: 0 additions & 1 deletion src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export default function LoginPage(props: LoginPageProps) {
minLength={authUser.properties.username.minLength}
onChange={(e) => setUsername(e.target.value)}
required

/>
</div>
<div className="flex flex-col border">
Expand Down
4 changes: 2 additions & 2 deletions src/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ interface RegisterPageProps {}

export default function RegisterPage(props: RegisterPageProps) {
const router = useRouter();
const { registerMuation } = AuthHook();
const { registerMutation } = AuthHook();
const [status, setStatus] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");

const onSubmit = async (e: FormEvent) => {
e.preventDefault();
registerMuation
registerMutation
.mutateAsync({
username,
password,
Expand Down
5 changes: 4 additions & 1 deletion src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import getQueryClient from "@/lib/query";
import getQueryClient from "@/lib/react-query";
import { rpc } from "@/lib/rpc";
import { setCookies } from "@/utils/server";
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
Expand All @@ -9,12 +9,15 @@ interface MainLayoutProps {
}

export default async function MainLayout(props: MainLayoutProps) {
// Get the QueryClient server component instance
const queryClient = getQueryClient();

// Fetch current user data set cookies are required else they will be empty
const { data: me, error: meError } = await rpc.api.user.me.get(setCookies());

if (meError) redirect("/login");

// Set the fetched user data in the query client cache `src/components/hooks/user-hook.ts`
queryClient.setQueryData(["me"], me);

return (
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/[[...route]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ import { authRoute } from "@/server/auth";
import { userRoute } from "@/server/user";
import { Elysia } from "elysia";

/**
* Main API router
* Combines auth and user routes under the '/api' prefix
*/
const app = new Elysia({ prefix: "/api", aot: false })
.use(userRoute)
.use(authRoute);

/**
* Export the app type for use with RPC clients (e.g., edenTreaty)
*/
export type App = typeof app;

/**
* Export handlers for different HTTP methods
* These are used by Next.js API routes [[...route]].ts
*/
export const GET = app.handle;
export const POST = app.handle;
export const PUT = app.handle;
Expand Down
16 changes: 14 additions & 2 deletions src/components/hooks/auth-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,39 @@ import { rpc } from "@/lib/rpc";
import { handleEden } from "@/utils/base";
import { useMutation } from "@tanstack/react-query";

/**
* Custom hook which can be imported in "use client" components
*/
export const AuthHook = () => {
const registerMuation = useMutation({
/**
* Uses the RPC client to call the register endpoint parse it with handleEden.
*/
const registerMutation = useMutation({
mutationKey: ["register"],
mutationFn: async (
...args: Parameters<typeof rpc.api.auth.register.post>
) => handleEden(await rpc.api.auth.register.post(...args)),
});

/**
* Uses the RPC client to call the login endpoint and parse it with handleEden.
*/
const loginMutation = useMutation({
mutationKey: ["login"],
mutationFn: async (...args: Parameters<typeof rpc.api.auth.login.post>) =>
handleEden(await rpc.api.auth.login.post(...args)),
});

/**
* Uses the RPC client to call the logout endpoint and parse it with handleEden.
*/
const logoutMutation = useMutation({
mutationKey: ["logout"],
mutationFn: async () => handleEden(await rpc.api.auth.logout.get()),
});

return {
registerMuation,
registerMutation,
loginMutation,
logoutMutation,
};
Expand Down
8 changes: 8 additions & 0 deletions src/components/hooks/user-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { rpc } from "@/lib/rpc";
import { handleEden } from "@/utils/base";
import { useQuery } from "@tanstack/react-query";

/**
* Custom hook which can be imported in "use client" components
*/
export const UserHook = () => {
/**
* Uses the RPC client to call the user 'me' endpoint and parse it with handleEden.
*
* Note: This query is disabled by default, because its loaded over server components in `src/app/(main)/layout.tsx` and doenst need to be fetched
*/
const meQuery = useQuery({
queryKey: ["me"],
enabled: false,
Expand Down
10 changes: 10 additions & 0 deletions src/components/providers/query-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,28 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";

/**
* QueryProvider component
* Sets up a QueryClientProvider for React Query to manage and cache API requests.
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Child components to be wrapped by the provider
*/
export default function QueryProvider({
children,
}: {
children: React.ReactNode;
}) {
// Create a new QueryClient instance and store it in state
// This ensures the same instance is used across re-renders
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
}),
);

// Wrap children with QueryClientProvider
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
Expand Down
16 changes: 0 additions & 16 deletions src/lib/db.ts

This file was deleted.

32 changes: 0 additions & 32 deletions src/lib/encrypt.ts

This file was deleted.

52 changes: 52 additions & 0 deletions src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { serverEnv } from "@/utils/env/server";
import * as jose from "jose";

// Create an encryption key from the server's SECRET environment variable
const key = new TextEncoder().encode(serverEnv.SECRET);

/**
* Encrypts a value using JSON Web Encryption (JWE)
* @param value - The value to encrypt (can be any type)
* @returns A Promise that resolves to the encrypted string, or null if encryption fails
*/
export const encrypt = async (value: any): Promise<string | null> => {
try {
// Convert objects to JSON strings, leave other types as-is
const text = typeof value === "object" ? JSON.stringify(value) : value;

// Create a JWE using direct encryption with AES-GCM
const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(text))
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.encrypt(key);

return jwe;
} catch (error) {
return null;
}
};

/**
* Decrypts a JWE string
* @param encryptedText - The JWE string to decrypt
* @returns A Promise that resolves to the decrypted value (as type T), or null if decryption fails
*/
export const decrypt = async <T = string>(
encryptedText: string,
): Promise<T | null> => {
if (typeof encryptedText !== "string") return null;
try {
// Decrypt the JWE string
const { plaintext } = await jose.compactDecrypt(encryptedText, key);
const decrypted = new TextDecoder().decode(plaintext);

// Attempt to parse the decrypted text as JSON
try {
return JSON.parse(decrypted) as T;
} catch (error) {
// If parsing fails, return the decrypted text as-is
return decrypted as T;
}
} catch (error) {
return null;
}
};
5 changes: 5 additions & 0 deletions src/lib/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export default prisma;
5 changes: 0 additions & 5 deletions src/lib/query.ts

This file was deleted.

15 changes: 15 additions & 0 deletions src/lib/react-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { QueryClient } from "@tanstack/react-query";
import { cache } from "react";

/**
* Creates and caches a global QueryClient instance for server components only.
*
* This function uses React's `cache` to ensure that only one QueryClient
* instance is created and reused across all server components. This helps
* prevent duplicate requests and maintains consistent state.
*
* @returns A cached instance of QueryClient
*/
const getQueryClient = cache(() => new QueryClient());

export default getQueryClient;
10 changes: 10 additions & 0 deletions src/lib/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { App } from "@/app/api/[[...route]]/route";
import { edenTreaty } from "@elysiajs/eden/treaty";

/**
* Creates an RPC client using edenTreaty.
*
* This setup allows for type-safe API calls between the client and server,
* leveraging the App type from the API route definition.
*
* The base URL for the RPC client is determined dynamically:
* - On the server side, it uses localhost with the specified PORT (or 3000 as default)
* - On the client side, it uses the current window's origin
*/
export const rpc = edenTreaty<App>(
typeof window === "undefined"
? `http://localhost:${process.env.PORT || 3000}`
Expand Down
Loading

0 comments on commit f4137a1

Please sign in to comment.