diff --git a/apps/web/app/(admin)/admin/page.tsx b/apps/web/app/(admin)/admin/page.tsx deleted file mode 100644 index 35a664b..0000000 --- a/apps/web/app/(admin)/admin/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; -import AdminPanelComponent from "@/components/admin-panel"; -import React from "react"; - -const page = () => { - return ( -
- -
- ); -}; - -export default page; diff --git a/apps/web/app/(admin)/layout.tsx b/apps/web/app/(admin)/layout.tsx deleted file mode 100644 index 2f0c0fb..0000000 --- a/apps/web/app/(admin)/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const Layout = ({ children }: { children: React.ReactNode }) => { - return
{children}
; -}; - -export default Layout; diff --git a/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx index 93a2447..684846e 100644 --- a/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -1,5 +1,5 @@ -import { ModeToggle } from "@/components/theme-toggle"; -import { Button } from "@/components/ui/button"; +import { ModeToggle } from "ui/components/theme-toggle"; +import {Button} from "ui/components/ui/button"; import { SignIn } from "@clerk/nextjs"; import { House } from "lucide-react"; import Link from "next/link"; diff --git a/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx index 003e0a2..eb71591 100644 --- a/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ b/apps/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -1,5 +1,5 @@ -import { ModeToggle } from "@/components/theme-toggle"; -import { Button } from "@/components/ui/button"; +import { ModeToggle } from "ui/components/theme-toggle"; +import { Button } from "ui/components/ui/button"; import { SignUp } from "@clerk/nextjs"; import { House } from "lucide-react"; import Link from "next/link"; diff --git a/apps/web/app/(root)/page.tsx b/apps/web/app/(root)/page.tsx index 60cb494..84083d9 100644 --- a/apps/web/app/(root)/page.tsx +++ b/apps/web/app/(root)/page.tsx @@ -1,10 +1,9 @@ +import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; import SignedDevSignUp from "ui/components/signed-out/dev"; import { SignedOutfooter } from "ui/components/signed-out/footer"; import Hero from "ui/components/signed-out/hero"; import SignedOutMobileNav from "ui/components/signed-out/mobile-nav"; import SignedOutNav from "ui/components/signed-out/nav"; -import { SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; -import React from "react"; const Home = () => { return ( diff --git a/apps/web/app/api/isadmin/route.ts b/apps/web/app/api/isadmin/route.ts deleted file mode 100644 index 5137b66..0000000 --- a/apps/web/app/api/isadmin/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getAuth } from '@clerk/nextjs/server'; -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const { userId } = await getAuth(request); - - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Get the host from the request headers - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const host = request.headers.get('host') || 'localhost:3000'; - - const response = await fetch(`${protocol}://${host}/api/userdata/${userId}`); - const data = await response.json(); - - const isAdmin = data.user.role === 'ADMIN'; - return NextResponse.json({ userId, isAdmin }); -} \ No newline at end of file diff --git a/apps/web/app/api/user/route.ts b/apps/web/app/api/user/route.ts deleted file mode 100644 index 89759d2..0000000 --- a/apps/web/app/api/user/route.ts +++ /dev/null @@ -1,13 +0,0 @@ - -import { getAuth } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' - -export async function GET(request: NextRequest) { - const { userId } = await getAuth(request) - - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - return NextResponse.json({ userId: userId }) -} \ No newline at end of file diff --git a/apps/web/components/signed-out/hero.tsx b/apps/web/components/signed-out/hero.tsx index 138a2a9..9f857bd 100644 --- a/apps/web/components/signed-out/hero.tsx +++ b/apps/web/components/signed-out/hero.tsx @@ -25,4 +25,4 @@ const Hero = () => { ); }; -export default Hero; +export default Hero; \ No newline at end of file diff --git a/apps/web/components/theme-toggle.tsx b/apps/web/components/theme-toggle.tsx index 9264016..653c922 100644 --- a/apps/web/components/theme-toggle.tsx +++ b/apps/web/components/theme-toggle.tsx @@ -4,13 +4,13 @@ import * as React from "react"; import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; -import { Button } from "@/components/ui/button"; +import { Button } from "./ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +} from "./ui/dropdown-menu"; export function ModeToggle() { const { setTheme } = useTheme(); diff --git a/packages/api/package.json b/packages/api/package.json index d4ca588..dbe0ee3 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,12 +13,16 @@ "dev": "NODE_ENV=development tsup --watch", "build": "NODE_ENV=production tsup" }, -"dependencies": { + "dependencies": { + "@clerk/backend": "^1.16.0", + "clerk-auth-middleware": "workspace:*", "database": "workspace:*", "glob": "^11.0.0", "hono": "^4.6.8", + "ioredis": "^5.4.1", "prisma": "^5.21.1", - "tsx": "^4.19.2" + "tsx": "^4.19.2", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^22.8.7", diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index 9dd044a..780e23c 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -1,2 +1,4 @@ +export { default as playerdata } from './playerdata'; export { default as test } from './test'; +export { default as user } from './user'; export { default as userdata } from './userdata'; \ No newline at end of file diff --git a/packages/api/src/routes/playerdata.ts b/packages/api/src/routes/playerdata.ts new file mode 100644 index 0000000..9a5c93e --- /dev/null +++ b/packages/api/src/routes/playerdata.ts @@ -0,0 +1,276 @@ +// packages/api/src/routes/playerdata.ts +import { createRouter } from "../router"; +import prisma from "../db"; +import Redis from "ioredis"; +import { createHmac } from "crypto"; +import { z } from "zod"; +import type { Prisma, Game, PlayerData } from "database"; +// Validation schemas +const PlayerDataSchema = z.object({ + userId: z.string(), + data: z.unknown(), + game: z.string(), + signature: z.string(), + timestamp: z.string(), +}); +// Types +interface QueueItem { + userId: string; + data: Prisma.JsonValue; + game: string; + signature: string; + timestamp: string; + queueTimestamp: number; + tempId: string; +} + +// Constants +const QUEUE_KEY = "playerdata:queue"; +const BATCH_SIZE = 100; +const PROCESS_INTERVAL = 5000; // 5 seconds + +// Initialize Redis client +const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", { + maxRetriesPerRequest: 3, + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, +}); + +// Type guard for QueueItem +function isQueueItem(item: unknown): item is QueueItem { + const queueItem = item as QueueItem; + return ( + typeof queueItem?.userId === "string" && + typeof queueItem?.game === "string" && + typeof queueItem?.signature === "string" && + typeof queueItem?.timestamp === "string" && + typeof queueItem?.queueTimestamp === "number" && + typeof queueItem?.tempId === "string" && + queueItem?.data !== undefined + ); +} + +// Security validation helper +function validateSignature( + payload: string, + timestamp: string, + signature: string, + gameSecret: string +): boolean { + const data = `${payload}:${timestamp}`; + const expectedSignature = createHmac("sha256", gameSecret) + .update(data) + .digest("hex"); + return expectedSignature === signature; +} + +// Game auth middleware +async function validateGameAuth(c: any, next: () => Promise) { + const gameId = c.req.header("X-Game-ID"); + const timestamp = c.req.header("X-Timestamp"); + const signature = c.req.header("X-Signature"); + + if (!gameId || !timestamp || !signature) { + return c.json( + { success: false, error: "Missing authentication headers" }, + 401 + ); + } + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true, secret: true }, + }); + + if (!game) { + return c.json({ success: false, error: "Game not found" }, 404); + } + + const payload = await c.req.raw.clone().text(); + if (!validateSignature(payload, timestamp, signature, game.secret)) { + return c.json({ success: false, error: "Invalid signature" }, 401); + } + + await next(); +} + +// Redis operations with type safety +class PlayerDataQueue { + static async pushItem(item: QueueItem): Promise { + await redis.lpush(QUEUE_KEY, JSON.stringify(item)); + } + + static async popItem(): Promise { + const item = await redis.rpop(QUEUE_KEY); + if (!item) return null; + + try { + const parsed = JSON.parse(item); + if (isQueueItem(parsed)) { + return parsed; + } + console.error("Invalid queue item format:", parsed); + return null; + } catch (error) { + console.error("Error parsing queue item:", error); + return null; + } + } + + static async processBatch(batchSize: number): Promise { + const items: QueueItem[] = []; + + for (let i = 0; i < batchSize; i++) { + const item = await this.popItem(); + if (!item) break; + items.push(item); + } + + return items; + } + + static async returnItemsToQueue(items: QueueItem[]): Promise { + const multi = redis.multi(); + items.forEach((item) => { + multi.lpush(QUEUE_KEY, JSON.stringify(item)); + }); + await multi.exec(); + } +} + +// Queue processor function +async function processQueue(): Promise { + try { + const items = await PlayerDataQueue.processBatch(BATCH_SIZE); + + if (items.length === 0) return; + + items.sort((a, b) => a.queueTimestamp - b.queueTimestamp); + + await prisma.$transaction(async (tx) => { + for (const item of items) { + const { userId, data, game } = item; + + await tx.playerData.upsert({ + where: { + userId_game: { + userId, + game, + }, + }, + update: { + data: data!, + }, + create: { + id: crypto.randomUUID(), + userId, + game, + data: data!, + }, + }); + } + }); + + console.log(`Processed ${items.length} player data items`); + } catch (error) { + console.error("Queue processing error:", error); + const items = await PlayerDataQueue.processBatch(BATCH_SIZE); + if (items.length > 0) { + await PlayerDataQueue.returnItemsToQueue(items); + } + } +} + +// Start queue processor +const queueInterval = setInterval(processQueue, PROCESS_INTERVAL); + +// Ensure cleanup on application shutdown +process.on("SIGTERM", () => { + clearInterval(queueInterval); + redis.disconnect(); +}); + +// Create and configure router +const router = createRouter() + // Submit player data + .post("/submit", validateGameAuth, async (c) => { + try { + const body = await c.req.json(); + const validatedData = PlayerDataSchema.parse(body); + + const queueItem: QueueItem = { + ...validatedData, + queueTimestamp: Date.now(), + tempId: crypto.randomUUID(), + data: z.array(z.string()).parse(validatedData.data), // Parse data as a JSON value + }; + + await PlayerDataQueue.pushItem(queueItem); + + return c.json( + { + success: true, + message: "Player data queued for processing", + tempId: queueItem.tempId, + }, + 202 + ); + } catch (error) { + if (error instanceof z.ZodError) { + return c.json({ success: false, errors: error.errors }, 400); + } + return c.json({ success: false, error: "Internal server error" }, 500); + } + }) + + // Get current user's data + .get("/me", validateGameAuth, async (c) => { + try { + const userId = c.req.header("X-User-ID"); + const gameId = c.req.header("X-Game-ID"); + + if (!userId) { + return c.json({ success: false, error: "User ID required" }, 400); + } + + const playerData = await prisma.playerData.findUnique({ + where: { + userId_game: { + userId, + game: gameId!, + }, + }, + }); + + return c.json({ + success: true, + data: playerData, + }); + } catch (error) { + return c.json({ success: false, error: "Internal server error" }, 500); + } + }) + + // Get all player data for a game + .get("/game/:gameId", validateGameAuth, async (c) => { + try { + const gameId = c.req.param("gameId"); + + const playerData = await prisma.playerData.findMany({ + where: { + game: gameId, + }, + }); + + return c.json({ + success: true, + data: playerData, + }); + } catch (error) { + return c.json({ success: false, error: "Internal server error" }, 500); + } + }); + +export default router; diff --git a/packages/api/src/routes/test.ts b/packages/api/src/routes/test.ts index 0fc56a3..5766cf2 100644 --- a/packages/api/src/routes/test.ts +++ b/packages/api/src/routes/test.ts @@ -19,6 +19,7 @@ const router = createRouter() version: "1.0.0", environment: process.env.NODE_ENV, }), + ); - + export default router; diff --git a/packages/api/src/routes/user.ts b/packages/api/src/routes/user.ts new file mode 100644 index 0000000..1dcff0f --- /dev/null +++ b/packages/api/src/routes/user.ts @@ -0,0 +1,25 @@ +import { createRouter } from "../router"; +import { clerkMiddleware, getAuth } from 'clerk-auth-middleware'; +import type { Context } from 'hono'; +import { Env } from "hono"; +const secretKey = process.env.CLERK_SECRET_KEY!; +const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY! +console.log(publishableKey); +const router = createRouter() + .use("/", async (c: Context, next) => { + const middleware = clerkMiddleware(secretKey, publishableKey); + return middleware(c as any, next); + }) + .get("/", (c) => { + const auth = getAuth(c as any); + + if (!auth?.userId) { + return c.json({ error: "Unauthorized" }, 401); + } + + return c.json({ + userId: auth.userId, + }); + }); + +export default router; \ No newline at end of file diff --git a/packages/api/src/routes/userdata.ts b/packages/api/src/routes/userdata.ts index e94b679..4105057 100644 --- a/packages/api/src/routes/userdata.ts +++ b/packages/api/src/routes/userdata.ts @@ -1,19 +1,21 @@ -// packages/api/src/routes/test.ts +// packages/api/src/routes/userdata.ts import { createRouter } from "../router"; import prisma from "../db"; +import { user } from "."; + const router = createRouter() + // this route is for getting user data and is at /api/userdata/id (the userdata bit comes from the file name) .get("/:id", async (c) => { const id = c.req.param("id"); - const user = await prisma.user.findUnique({ - where: { - clerkId: id - } + where: { + clerkId: id, + }, }); return c.json({ - user + user, }); - }); + }) -export default router; \ No newline at end of file +export default router; diff --git a/packages/clerk-hono-middleware/README.md b/packages/clerk-hono-middleware/README.md new file mode 100644 index 0000000..a5ad8e8 --- /dev/null +++ b/packages/clerk-hono-middleware/README.md @@ -0,0 +1,79 @@ +# Clerk middleware for Hono + +This is a [Clerk](https://clerk.com) third-party middleware for [Hono](https://github.com/honojs/hono). + +This middleware can be used to inject the active Clerk session into the request context. + +## Installation + +```plain +npm i hono @hono/clerk-auth @clerk/backend +``` + +## Configuration + +Before starting using the middleware you must set the following environment variables: + +```plain +CLERK_SECRET_KEY= +CLERK_PUBLISHABLE_KEY= +``` + +## How to Use + +```ts +import { clerkMiddleware, getAuth } from '@hono/clerk-auth' +import { Hono } from 'hono' + +const app = new Hono() + +app.use('*', clerkMiddleware()) +app.get('/', (c) => { + const auth = getAuth(c) + + if (!auth?.userId) { + return c.json({ + message: 'You are not logged in.' + }) + } + + return c.json({ + message: 'You are logged in!', + userId: auth.userId + }) +}) + +export default app +``` + +## Accessing instance of Backend API client + +```ts +import { clerkMiddleware, getAuth } from '@hono/clerk-auth' +import { Hono } from 'hono' + +const app = new Hono() + +app.use('*', clerkMiddleware()) +app.get('/', async (c) => { + const clerkClient = c.get('clerk') + + try { + const user = await clerkClient.users.getUser('user_id_....') + + return c.json({ + user, + }) + } catch (e) { + return c.json({ + message: 'User not found.' + }, 404) + } +}) + +export default app +``` + +## Author + +Vaggelis Yfantis diff --git a/packages/clerk-hono-middleware/package.json b/packages/clerk-hono-middleware/package.json new file mode 100644 index 0000000..0a8ceec --- /dev/null +++ b/packages/clerk-hono-middleware/package.json @@ -0,0 +1,39 @@ +{ + "name": "clerk-auth-middleware", + "version": "2.0.0", + "description": "A third-party Clerk auth middleware for Hono", "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --format esm,cjs --dts" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "license": "MIT", + "peerDependencies": { + "@clerk/backend": "^1.0.0", + "hono": ">=3.*" + }, + "devDependencies": { + "@clerk/backend": "^1.0.0", + "@types/react": "^18", + "hono": "^3.11.7", + "node-fetch-native": "^1.4.0", + "react": "^18.2.0", + "tsup": "^8.0.1" + } +} diff --git a/packages/clerk-hono-middleware/src/index.ts b/packages/clerk-hono-middleware/src/index.ts new file mode 100644 index 0000000..521ddbf --- /dev/null +++ b/packages/clerk-hono-middleware/src/index.ts @@ -0,0 +1,60 @@ +import { type ClerkClient, type ClerkOptions, createClerkClient } from '@clerk/backend' +import type { Context, MiddlewareHandler } from 'hono' + +type ClerkAuth = ReturnType>['toAuth']> + +declare module 'hono' { + interface ContextVariableMap { + clerk: ClerkClient + clerkAuth: ClerkAuth + } +} + +export const getAuth = (c: Context) => { + return c.get('clerkAuth') +} + +export const clerkMiddleware = ( + secretKey: string, + publishableKey: string, + options?: Omit +): MiddlewareHandler => { + return async (c, next) => { + if (!secretKey) { + throw new Error('Missing Clerk Secret key') + } + + if (!publishableKey) { + throw new Error('Missing Clerk Publishable key') + } + + const clerkClient = createClerkClient({ + ...options, + secretKey, + publishableKey, + }) + + const requestState = await clerkClient.authenticateRequest(c.req.raw, { + ...options, + secretKey, + publishableKey, + }) + + if (requestState.headers) { + requestState.headers.forEach((value, key) => c.res.headers.append(key, value)) + + const locationHeader = requestState.headers.get('location') + + if (locationHeader) { + return c.redirect(locationHeader, 307) + } else if (requestState.status === 'handshake') { + throw new Error('Clerk: unexpected handshake without redirect') + } + } + + c.set('clerkAuth', requestState.toAuth()) + c.set('clerk', clerkClient) + + await next() + } +} \ No newline at end of file diff --git a/packages/clerk-hono-middleware/tsconfig.json b/packages/clerk-hono-middleware/tsconfig.json new file mode 100644 index 0000000..697dda4 --- /dev/null +++ b/packages/clerk-hono-middleware/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2020", + "module": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "moduleResolution": "bundler", + "baseUrl": ".", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 7db2070..b603ebd 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1,27 +1,45 @@ generator client { -provider = "prisma-client-js" -previewFeatures = ["driverAdapters"] + provider = "prisma-client-js" + previewFeatures = ["driverAdapters"] } datasource db { -provider = "postgresql" -url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") } model User { -id String @id @default(cuid()) -email String @unique -createdAt DateTime @default(now()) -updatedAt DateTime @updatedAt -username String? @unique -clerkId String @unique -image_url String? -credits Int @default(200) -role Role @default(USER) + id String @id @default(cuid()) + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + username String? @unique + clerkId String @unique + image_url String? + credits Int @default(200) + role Role @default(USER) } enum Role { -USER -ADMIN -MODERATOR -} \ No newline at end of file + USER + ADMIN + MODERATOR +} + +model Game { + id String @unique @default(cuid()) + createdBy String + data Json + imageUrl String + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + secret String @unique @default(cuid()) +} + +model PlayerData { + userId String + data Json + game String + id String @default(cuid()) + @@unique([userId, game]) +} \ No newline at end of file diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 94c9244..f22cb56 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,7 +1,9 @@ export * from '@prisma/client'; export { PrismaClient as PrismaClientEdge } from '@prisma/client/edge'; - +export type { Prisma } from "@prisma/client"; +export type { Game } from "@prisma/client"; +export type { PlayerData } from "@prisma/client"; import { Pool, neonConfig } from '@neondatabase/serverless' import { PrismaNeon } from '@prisma/adapter-neon' import { PrismaClient } from '@prisma/client' diff --git a/packages/dev-node-sdk/package.json b/packages/dev-node-sdk/package.json index b5a1f0f..2f3d5d1 100644 --- a/packages/dev-node-sdk/package.json +++ b/packages/dev-node-sdk/package.json @@ -1,20 +1,29 @@ { - "name": "@gamex/gamex-node-sdk", + "name": "@gamex-official/gamex-node-sdk", "version": "1.0.0", "description": "Developer SDK for Gamex", "private": false, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "gamex" + ], + "author": "Brrock", + "license": "MIT", "devDependencies": { - "@types/node": "^22.9.0", + "@types/node": "^20.11.0", "eslint": "^9", "prettier": "^3.3.3", "typescript": "^5.6.3" }, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/brrock/gamex.git", "directory": "packages/dev-node-sdk" + }, + "dependencies": { + "tsup": "^6.7.0", + "zod": "^3.23.8" } } diff --git a/packages/dev-node-sdk/readme.md b/packages/dev-node-sdk/readme.md new file mode 100644 index 0000000..8811548 --- /dev/null +++ b/packages/dev-node-sdk/readme.md @@ -0,0 +1,403 @@ +# GameX Developer SDK Documentation + +## Table of Contents + +- [GameX Developer SDK Documentation](#gamex-developer-sdk-documentation) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Getting Started](#getting-started) + - [Schema Definition](#schema-definition) + - [Basic Schema Example](#basic-schema-example) + - [Advanced Schema Examples](#advanced-schema-examples) + - [SDK Initialization](#sdk-initialization) + - [Environment Configuration](#environment-configuration) + - [React Integration](#react-integration) + - [Custom Hooks](#custom-hooks) + - [React Component Examples](#react-component-examples) + - [Auto-Save Implementation](#auto-save-implementation) + - [Game State Management with Context](#game-state-management-with-context) + - [Error Handling](#error-handling) + - [TypeScript Integration](#typescript-integration) + - [Best Practices](#best-practices) + - [Troubleshooting](#troubleshooting) + +## Installation + +```bash +# Using npm +npm install @gamex-official/gamex-node-sdk zod + +# Using yarn +yarn add @gamex-official/gamex-node-sdk zod + +# Using pnpm +pnpm add @gamex-official/gamex-node-sdk zod +``` + +## Getting Started + +The GameX SDK provides a type-safe way to manage game data with built-in validation. Import the necessary dependencies: + +```typescript +import { GameSDK } from '@gamex-official/gamex-node-sdk'; +import { z } from 'zod'; +``` + +## Schema Definition + +Define your game's data structure using Zod schemas. This ensures type safety and runtime validation. + +### Basic Schema Example + +```typescript +const PlayerProgressSchema = z.object({ + level: z.number().min(1).max(100), + experience: z.number().nonnegative(), + inventory: z.array(z.object({ + itemId: z.string(), + quantity: z.number().positive(), + })), + lastSaved: z.string().datetime(), + achievements: z.array(z.string()), + settings: z.object({ + musicVolume: z.number().min(0).max(1), + sfxVolume: z.number().min(0).max(1), + difficulty: z.enum(['easy', 'normal', 'hard']), + }).optional(), +}); +``` + +### Advanced Schema Examples + +```typescript +// Game State Schema +const GameStateSchema = z.object({ + currentLevel: z.string(), + playerPosition: z.object({ + x: z.number(), + y: z.number(), + z: z.number() + }), + activeQuests: z.array(z.object({ + id: z.string(), + progress: z.number().min(0).max(100), + completed: z.boolean(), + timeStarted: z.string().datetime() + })), + gameVersion: z.string(), + lastCheckpoint: z.string().optional() +}); + +// Player Stats Schema +const PlayerStatsSchema = z.object({ + health: z.number().min(0).max(100), + mana: z.number().min(0).max(100), + attributes: z.object({ + strength: z.number().min(1), + dexterity: z.number().min(1), + intelligence: z.number().min(1) + }), + skills: z.record(z.string(), z.number().min(0).max(100)) +}); +``` + +## SDK Initialization + +Initialize the SDK with your credentials: + +```typescript +const gameSDK = new GameSDK({ + gameId: process.env.GAME_ID, + gameSecret: process.env.GAME_SECRET, + environment: process.env.NODE_ENV, // 'development' | 'production' + options: { + autoRetry: true, + maxRetries: 3, + timeout: 5000 + } +}); + +const client = gameSDK.client(PlayerProgressSchema); +``` + +### Environment Configuration + +```typescript +// .env.local +NEXT_PUBLIC_GAME_ID=your_game_id +GAME_SECRET=your_game_secret +NEXT_PUBLIC_DEV_MODE=true # Development mode +``` + +## React Integration + +### Custom Hooks + +```typescript +// hooks/useGameState.ts +import { useEffect, useState } from 'react'; +import { GameSDK } from '@gamex-official/gamex-node-sdk'; + +export function useGameState(client: GameSDK) { + const [gameData, setGameData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchGameData() { + try { + const data = await client.getMyPlayerData(); + setGameData(data); + } catch (err) { + setError(err instanceof Error ? err : new Error('Unknown error')); + } finally { + setLoading(false); + } + } + + fetchGameData(); + }, [client]); + + return { gameData, loading, error }; +} +``` + +### React Component Examples + +```typescript +// components/GameProgress.tsx +import React from 'react'; +import { useGameState } from '../hooks/useGameState'; + +type PlayerProgress = z.infer; + +export function GameProgress({ client }: { client: GameSDK }) { + const { gameData, loading, error } = useGameState(client); + + if (loading) return
Loading game data...
; + if (error) return
Error: {error.message}
; + if (!gameData) return
No game data available
; + + return ( +
+

Player Progress

+
Level: {gameData.level}
+
Experience: {gameData.experience}
+
+

Inventory

+ {gameData.inventory.map(item => ( +
+ {item.itemId}: {item.quantity} +
+ ))} +
+
+ ); +} +``` + +### Auto-Save Implementation + +```typescript +// components/AutoSave.tsx +import React, { useEffect } from 'react'; + +export function AutoSave({ client, gameData, interval = 60000 }) { + useEffect(() => { + const saveInterval = setInterval(async () => { + try { + await client.sendPlayerData({ + ...gameData, + lastSaved: new Date().toISOString() + }); + console.log('Auto-save successful'); + } catch (error) { + console.error('Auto-save failed:', error); + } + }, interval); + + return () => clearInterval(saveInterval); + }, [client, gameData, interval]); + + return null; +} + + +## Advanced Examples + +### Implementing a Save System + +``ts +// utils/saveSystem.ts +export class SaveSystem { + private client: GameSDK; + private autoSaveInterval: number; + private lastSave: Date; + + constructor(client: GameSDK, autoSaveInterval = 60000) { + this.client = client; + this.autoSaveInterval = autoSaveInterval; + this.lastSave = new Date(); + } + + async quickSave(data: PlayerProgress) { + try { + await this.client.sendPlayerData({ + ...data, + lastSaved: new Date().toISOString() + }); + this.lastSave = new Date(); + return true; + } catch (error) { + console.error('Quick save failed:', error); + return false; + } + } + + async loadGame(): Promise { + try { + return await this.client.getMyPlayerData(); + } catch (error) { + console.error('Load game failed:', error); + return null; + } + } + + startAutoSave(data: PlayerProgress) { + return setInterval(() => this.quickSave(data), this.autoSaveInterval); + } +} +``` + +### Game State Management with Context + +```typescript +// context/GameContext.tsx +import React, { createContext, useContext, useState } from 'react'; + +const GameContext = createContext<{ + gameState: PlayerProgress | null; + setGameState: (state: PlayerProgress) => void; +} | null>(null); + +export function GameProvider({ children }: { children: React.ReactNode }) { + const [gameState, setGameState] = useState(null); + + return ( + + {children} + + ); +} + +export function useGameContext() { + const context = useContext(GameContext); + if (!context) { + throw new Error('useGameContext must be used within a GameProvider'); + } + return context; +} +``` + +## Error Handling + +```typescript +// utils/errorHandling.ts +export class GameError extends Error { + constructor( + message: string, + public code: string, + public details?: any + ) { + super(message); + this.name = 'GameError'; + } +} + +export function handleGameError(error: unknown) { + if (error instanceof GameSDKError) { + switch (error.code) { + case 'VALIDATION_ERROR': + console.error('Data validation failed:', error.validationErrors); + // Handle validation errors + break; + case 'NETWORK_ERROR': + console.error('Network error:', error.message); + // Handle network errors + break; + default: + console.error('Unknown error:', error); + } + } +} +``` + +## TypeScript Integration + +```typescript +// types/game.ts +type GameSDKOptions = { + autoRetry: boolean; + maxRetries: number; + timeout: number; +}; + +type GameConfig = { + gameId: string; + gameSecret: string; + environment: 'development' | 'production'; + options?: Partial; +}; + +// Utility types +type InventoryItem = z.infer['inventory'][number]; +type GameSettings = z.infer['settings']; +``` + +## Best Practices + +1. **State Management** + - Use React Context for global game state + - Implement proper data persistence strategies + - Handle loading and error states appropriately + +2. **Performance** + - Implement debouncing for frequent save operations + - Use memoization for expensive calculations + - Optimize render cycles with React.memo and useMemo + +3. **Security** + - Never expose game secrets in client-side code + - Implement proper validation for all user inputs + - Use environment variables for sensitive data + +4. **Error Recovery** + - Implement auto-save recovery mechanisms + - Provide manual save/load functionality + - Keep backup saves when updating data + +## Troubleshooting + +Common issues and solutions: + +1. **Validation Errors** + - Check schema definitions match your data structure + - Ensure all required fields are present + - Verify data types and ranges + +2. **Network Issues** + - Implement retry logic for failed requests + - Add proper error handling for offline scenarios + - Cache data locally when appropriate + +3. **State Management** + - Use proper state initialization + - Handle loading states correctly + - Implement proper error boundaries + +4. **Performance** + - Optimize save frequency + - Implement proper data caching + - Monitor memory usage + +For additional support, please refer to the official GameX documentation or contact support. diff --git a/packages/dev-node-sdk/src/client.ts b/packages/dev-node-sdk/src/client.ts new file mode 100644 index 0000000..d816189 --- /dev/null +++ b/packages/dev-node-sdk/src/client.ts @@ -0,0 +1,263 @@ +import { CONFIG } from './config'; +import { SecurityManager } from './security'; +import { PlayerDataPayload, GameConfig } from './types'; +import { GameSDKError } from './errors'; +import { z } from 'zod'; + +export class PlayerDataClient { + private security: SecurityManager; + private baseUrl: string; + private gameId: string; + private userId: string | null = null; + private dataSchema?: z.ZodType; + private devMode: boolean; + + constructor(config: GameConfig, dataSchema?: z.ZodType) { + if (!config.gameId || !config.gameSecret) { + throw new GameSDKError( + 'Game ID and Game Secret are required', + 'INVALID_CONFIG' + ); + } + + this.security = new SecurityManager(config.gameId, config.gameSecret); + this.baseUrl = config.apiUrl || CONFIG.API_URL; + this.gameId = config.gameId; + this.dataSchema = dataSchema; + this.devMode = CONFIG.DEV_MODE; + } + + private async initializeUserId(): Promise { + if (this.userId) return; + + try { + const response = await fetch(`${this.baseUrl}${CONFIG.ENDPOINTS.USER_ID}`); + + if (!response.ok) { + throw new GameSDKError( + 'Failed to fetch user ID', + 'AUTH_ERROR', + response.status + ); + } + + const data = await response.json(); + this.userId = data.userId; + + if (!this.userId) { + throw new GameSDKError( + 'No user ID returned from server', + 'AUTH_ERROR' + ); + } + } catch (error) { + if (error instanceof GameSDKError) { + throw error; + } + throw new GameSDKError( + `Failed to initialize user ID: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'AUTH_ERROR' + ); + } + } + + async sendPlayerData(data: T): Promise { + // Validate data against schema if provided + if (this.dataSchema) { + try { + this.dataSchema.parse(data); + } catch (error) { + if (error instanceof z.ZodError) { + throw new GameSDKError( + 'Invalid player data format', + 'VALIDATION_ERROR', + 400, + error.errors + ); + } + throw error; + } + } + + await this.initializeUserId(); + + // In dev mode, log data instead of sending + if (this.devMode) { + console.log('[DEV MODE] Would send player data:', { + userId: this.userId, + gameId: this.gameId, + data + }); + return; + } + + const payload = { + userId: this.userId, + data, + game: this.gameId, + }; + + const stringifiedPayload = JSON.stringify(payload); + const headers = this.security.getAuthHeaders(stringifiedPayload); + + try { + const response = await fetch(`${this.baseUrl}${CONFIG.ENDPOINTS.PLAYER_DATA}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-User-ID': this.userId!, + ...headers + }, + body: stringifiedPayload + }); + + if (!response.ok) { + const error = await response.json(); + throw new GameSDKError( + error.message || 'Failed to send player data', + error.code, + response.status, + error.errors + ); + } + } catch (error) { + if (error instanceof GameSDKError) { + throw error; + } + throw new GameSDKError( + `Failed to send player data: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'API_ERROR' + ); + } + } + + async getMyPlayerData(): Promise { + await this.initializeUserId(); + + // In dev mode, return mock data + if (this.devMode) { + console.log('[DEV MODE] Would fetch player data for user:', this.userId); + return {} as T; + } + + const headers = this.security.getAuthHeaders(''); + + try { + const response = await fetch( + `${this.baseUrl}${CONFIG.ENDPOINTS.PLAYER_DATA}/me`, + { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-User-ID': this.userId!, + ...headers + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new GameSDKError( + error.message || 'Failed to fetch player data', + error.code, + response.status + ); + } + + const result = await response.json(); + const data = result.data?.data || null; + + // Validate response data against schema if provided + if (data && this.dataSchema) { + try { + return this.dataSchema.parse(data); + } catch (error) { + if (error instanceof z.ZodError) { + throw new GameSDKError( + 'Invalid player data format in response', + 'VALIDATION_ERROR', + 400, + error.errors + ); + } + throw error; + } + } + + return data as T; + } catch (error) { + if (error instanceof GameSDKError) { + throw error; + } + throw new GameSDKError( + `Failed to fetch player data: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'API_ERROR' + ); + } + } + + async getGameData(): Promise { + await this.initializeUserId(); + + // In dev mode, return mock data + if (this.devMode) { + console.log('[DEV MODE] Would fetch all game data for game:', this.gameId); + return [] as T[]; + } + + const headers = this.security.getAuthHeaders(''); + + try { + const response = await fetch( + `${this.baseUrl}${CONFIG.ENDPOINTS.PLAYER_DATA}/game/${this.gameId}`, + { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...headers + } + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new GameSDKError( + error.message || 'Failed to fetch game data', + error.code, + response.status + ); + } + + const result = await response.json(); + const dataArray = result.data?.map((item: PlayerDataPayload) => item.data) || []; + + // Validate response data against schema if provided + if (this.dataSchema) { + try { + return dataArray.map(data => this.dataSchema!.parse(data)); + } catch (error) { + if (error instanceof z.ZodError) { + throw new GameSDKError( + 'Invalid player data format in response', + 'VALIDATION_ERROR', + 400, + error.errors + ); + } + throw error; + } + } + + return dataArray as T[]; + } catch (error) { + if (error instanceof GameSDKError) { + throw error; + } + throw new GameSDKError( + `Failed to fetch game data: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'API_ERROR' + ); + } + } +} \ No newline at end of file diff --git a/packages/dev-node-sdk/src/config.ts b/packages/dev-node-sdk/src/config.ts new file mode 100644 index 0000000..98027f6 --- /dev/null +++ b/packages/dev-node-sdk/src/config.ts @@ -0,0 +1,11 @@ +export const CONFIG = { + API_URL: process.env.NEXT_PUBLIC_DEVELOPING_API === 'true' + ? 'http://localhost:3000' + : 'https://gamex.benjyross.xyz', + API_VERSION: 'v1', + ENDPOINTS: { + PLAYER_DATA: '/api/player-data', + USER_ID: '/api/user' + }, + DEV_MODE: process.env.NEXT_PUBLIC_DEV_MODE === 'true' +}; \ No newline at end of file diff --git a/packages/dev-node-sdk/src/errors.ts b/packages/dev-node-sdk/src/errors.ts new file mode 100644 index 0000000..fd26e76 --- /dev/null +++ b/packages/dev-node-sdk/src/errors.ts @@ -0,0 +1,13 @@ +export class GameSDKError extends Error { + code?: string; + status?: number; + details?: any; + + constructor(message: string, code?: string, status?: number, details?: any) { + super(message); + this.name = 'GameSDKError'; + this.code = code; + this.status = status; + this.details = details; + } +} \ No newline at end of file diff --git a/packages/dev-node-sdk/src/index.ts b/packages/dev-node-sdk/src/index.ts new file mode 100644 index 0000000..a8ba3bd --- /dev/null +++ b/packages/dev-node-sdk/src/index.ts @@ -0,0 +1,17 @@ +import { PlayerDataClient } from './client'; +import { GameSDKError } from './errors'; +import type { GameConfig } from './types'; + +export class GameSDK { + private client: PlayerDataClient; + + constructor(config: GameConfig) { + this.client = new PlayerDataClient(config); + } + + async savePlayerData(data: Record): Promise { + await this.client.sendPlayerData(data); + } +} + +export type { GameConfig, GameSDKError }; \ No newline at end of file diff --git a/packages/dev-node-sdk/src/security.ts b/packages/dev-node-sdk/src/security.ts new file mode 100644 index 0000000..96eeab6 --- /dev/null +++ b/packages/dev-node-sdk/src/security.ts @@ -0,0 +1,28 @@ +import { createHmac } from 'crypto'; +export class SecurityManager { + private gameId: string; + private gameSecret: string; + + constructor(gameId: string, gameSecret: string) { + this.gameId = gameId; + this.gameSecret = gameSecret; + } + + generateRequestSignature(payload: string, timestamp: number): string { + const data = `${payload}:${timestamp}`; + return createHmac('sha256', this.gameSecret) + .update(data) + .digest('hex'); + } + + getAuthHeaders(payload: string): Record { + const timestamp = Date.now(); + const signature = this.generateRequestSignature(payload, timestamp); + + return { + 'X-Game-ID': this.gameId, + 'X-Timestamp': timestamp.toString(), + 'X-Signature': signature + }; + } +} diff --git a/packages/dev-node-sdk/src/types.ts b/packages/dev-node-sdk/src/types.ts new file mode 100644 index 0000000..62e0ac8 --- /dev/null +++ b/packages/dev-node-sdk/src/types.ts @@ -0,0 +1,15 @@ +export interface PlayerDataPayload { + data: Record; + game: string; +} + +export interface GameConfig { + gameId: string; + gameSecret: string; + apiUrl?: string; +} + +export interface ApiError extends Error { + code?: string; + status?: number; +} \ No newline at end of file diff --git a/packages/dev-node-sdk/tsup.config.ts b/packages/dev-node-sdk/tsup.config.ts new file mode 100644 index 0000000..3529001 --- /dev/null +++ b/packages/dev-node-sdk/tsup.config.ts @@ -0,0 +1,10 @@ +import { Options, defineConfig } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + injectStyle: true, + ...options, +})); diff --git a/packages/ui/components/signed-out/hero.tsx b/packages/ui/components/signed-out/hero.tsx index 138a2a9..dc335e4 100644 --- a/packages/ui/components/signed-out/hero.tsx +++ b/packages/ui/components/signed-out/hero.tsx @@ -1,19 +1,57 @@ -import React from "react"; +"use client" +import React, { useState, useMemo, useEffect } from "react"; import { Button } from "../ui/button"; import Link from "next/link"; +import { motion } from "framer-motion"; const Hero = () => { + const [titleNumber, setTitleNumber] = useState(0); + const adjectives = useMemo( + () => ["funnest", "freshest", "coolest", "greatest", "newest"], + [] + ); + + useEffect(() => { + const timeoutId = setTimeout(() => { + if (titleNumber === adjectives.length - 1) { + setTitleNumber(0); + } else { + setTitleNumber(titleNumber + 1); + } + }, 2000); + return () => clearTimeout(timeoutId); + }, [titleNumber, adjectives]); + return (

GameX - Games for all

-

- Come play funnest, freshest and most popular games. Get your free - account today. +

+ Come play{" "} + + {adjectives.map((word, index) => ( + index ? -50 : 50, + opacity: titleNumber === index ? 1 : 0, + }} + transition={{ + y: { type: "spring", stiffness: 100, damping: 15 }, + opacity: { duration: 0.2 }, + }} + > + + {word} + + ))} + + , and most popular games. Get your free account today.

-