Abhinay Claude Opus 4.7 (1M context) commited on
Commit
810ed38
·
1 Parent(s): 9e5f6f6

Add VITE_API_BASE + HF Space and Vercel deploy configs

Browse files

Split-deploy support so backend can live on a Hugging Face Space (Docker
SDK) and frontend on Vercel. All fetch/EventSource calls now go through
src/api.js which prepends VITE_API_BASE at build time; falls back to
relative URLs for dev (Vite proxy) and monolithic deploys (Express serves
dist/).

- README.md frontmatter for HF Space (sdk: docker, app_port: 8080)
- vercel.json for the Vite frontend (rewrite /controller to index.html)
- .env.example documents every env var

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- backend (server/server.js) ---
2
+ MONGO_URI=mongodb+srv://<user>:<pass>@<cluster>/news?retryWrites=true&w=majority
3
+ MONGO_DB=news
4
+ MONGO_COLLECTION=egg_catcher_scores
5
+ # PORT is set by the host (HF Space, Fly, etc.); defaults to 5174 locally.
6
+
7
+ # --- frontend build (vite) ---
8
+ # Empty in monolithic deploy (frontend served by same Express).
9
+ # In split deploy, set to your backend URL (no trailing slash), e.g.:
10
+ # VITE_API_BASE=https://abhinay-egg-catcher-api.hf.space
11
+ VITE_API_BASE=
README.md CHANGED
@@ -1,7 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
1
  # egg-catcher
2
 
3
  Pixel-art egg-catcher arcade. Laptop = main screen with a QR pop-up; phone = controller. MongoDB-backed global leaderboard.
4
 
 
 
5
  ## Run locally
6
 
7
  ```bash
@@ -11,7 +24,7 @@ npm run dev
11
 
12
  `http://localhost:5173/` on the laptop. Phone scans the QR (same wifi).
13
 
14
- `.env` (already gitignored) needs:
15
 
16
  ```
17
  MONGO_URI=mongodb+srv://...
@@ -19,26 +32,34 @@ MONGO_DB=news
19
  MONGO_COLLECTION=egg_catcher_scores
20
  ```
21
 
22
- ## Deploy to Fly.io
23
 
24
- One-time:
25
 
26
- ```bash
27
- flyctl auth login
28
- flyctl launch --no-deploy # accept fly.toml, pick an app name
29
- flyctl secrets set MONGO_URI="mongodb+srv://abhinayabhi226_db_user:Od1IwrzfJbXybXap@cluster0.ctylbok.mongodb.net/news?retryWrites=true&w=majority"
30
- flyctl deploy
31
- ```
32
 
33
- Subsequent deploys:
34
 
35
- ```bash
36
- flyctl deploy
37
- ```
 
 
 
 
 
 
 
 
38
 
39
  ## How it works
40
 
41
- - Single Express server: serves the built Vite frontend AND the API.
42
  - Game rooms are in-memory; SSE relays controller events laptop ⇄ phone.
43
  - `/api/score`, `/api/leaderboard` write to MongoDB collection `egg_catcher_scores`.
44
- - In production the controller URL is `https://<your-app>.fly.dev/controller?room=XXXXXX`. In dev it's `http://<LAN-IP>:5173/controller?room=XXXXXX`.
 
1
+ ---
2
+ title: Egg Catcher
3
+ emoji: 🥚
4
+ colorFrom: yellow
5
+ colorTo: red
6
+ sdk: docker
7
+ app_port: 8080
8
+ pinned: false
9
+ short_description: Pixel-art arcade with phone-as-controller and MongoDB leaderboard
10
+ ---
11
+
12
  # egg-catcher
13
 
14
  Pixel-art egg-catcher arcade. Laptop = main screen with a QR pop-up; phone = controller. MongoDB-backed global leaderboard.
15
 
16
+ The frontmatter above is for [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces-config-reference) — it's ignored on GitHub.
17
+
18
  ## Run locally
19
 
20
  ```bash
 
24
 
25
  `http://localhost:5173/` on the laptop. Phone scans the QR (same wifi).
26
 
27
+ `.env` (gitignored) needs:
28
 
29
  ```
30
  MONGO_URI=mongodb+srv://...
 
32
  MONGO_COLLECTION=egg_catcher_scores
33
  ```
34
 
35
+ ## Deploy backend on HF Space, frontend on Vercel
36
 
37
+ ### 1. Backend on Hugging Face
38
 
39
+ 1. Go to https://huggingface.co/new-space
40
+ 2. **Owner**: your account · **Space name**: e.g. `egg-catcher-api` · **License**: MIT · **Space SDK**: pick **Docker** → **Blank**
41
+ 3. Hardware: **CPU basic — free**
42
+ 4. Create. Then in the Space, **Settings** → **Variables and secrets** add a *Secret* named `MONGO_URI` with your full Mongo connection string
43
+ 5. **Settings** → **Repository** → **Connect to a GitHub repository** → pick `ABHINAY2025/egg-catcher`. Every push to `main` will rebuild the Space
44
+ 6. The Space will build and come up at `https://<username>-<spacename>.hf.space`
45
 
46
+ ### 2. Frontend on Vercel
47
 
48
+ 1. Go to https://vercel.com/new
49
+ 2. Import `ABHINAY2025/egg-catcher`
50
+ 3. **Framework Preset**: Vite (auto-detected)
51
+ 4. **Environment Variables**: add `VITE_API_BASE` = `https://<your-hf-space-url>` (no trailing slash, e.g. `https://abhinay-egg-catcher-api.hf.space`)
52
+ 5. Deploy. Your game lives at `https://<project>.vercel.app`
53
+
54
+ ### Why split?
55
+
56
+ - Vercel gives CDN-fast static delivery + instant SPA routing.
57
+ - HF Space stays warm with no card required and supports long-lived SSE for the phone-controller pairing.
58
+ - Frontend and backend are decoupled — swap either independently.
59
 
60
  ## How it works
61
 
62
+ - Single Express server: serves `/api/*` and (in monolithic deploy) the built Vite frontend.
63
  - Game rooms are in-memory; SSE relays controller events laptop ⇄ phone.
64
  - `/api/score`, `/api/leaderboard` write to MongoDB collection `egg_catcher_scores`.
65
+ - Controller URL in prod = `${window.location.origin}/controller?room=XXXXXX` (the frontend's URL, not the API).
src/App.jsx CHANGED
@@ -3,6 +3,7 @@ import UsernameGate from './UsernameGate.jsx';
3
  import Game from './Game.jsx';
4
  import MobileGame from './MobileGame.jsx';
5
  import Leaderboard from './Leaderboard.jsx';
 
6
 
7
  function detectMobile() {
8
  if (typeof window === 'undefined') return false;
@@ -37,12 +38,12 @@ export default function App() {
37
  let alive = true;
38
  (async () => {
39
  try {
40
- const r1 = await fetch('/api/game/room', { method: 'POST' }).then((r) => r.json());
41
  if (!alive) return;
42
  setRoomId(r1.roomId);
43
  if (import.meta.env.PROD) return; // in prod, controller URL = window.location.origin
44
  try {
45
- const r2 = await fetch('/api/game/local-ip').then((r) => r.json());
46
  if (!alive) return;
47
  setLanIp(r2.ip);
48
  setIpCandidates(Array.isArray(r2.candidates) ? r2.candidates : []);
 
3
  import Game from './Game.jsx';
4
  import MobileGame from './MobileGame.jsx';
5
  import Leaderboard from './Leaderboard.jsx';
6
+ import { api } from './api.js';
7
 
8
  function detectMobile() {
9
  if (typeof window === 'undefined') return false;
 
38
  let alive = true;
39
  (async () => {
40
  try {
41
+ const r1 = await fetch(api('/api/game/room'), { method: 'POST' }).then((r) => r.json());
42
  if (!alive) return;
43
  setRoomId(r1.roomId);
44
  if (import.meta.env.PROD) return; // in prod, controller URL = window.location.origin
45
  try {
46
+ const r2 = await fetch(api('/api/game/local-ip')).then((r) => r.json());
47
  if (!alive) return;
48
  setLanIp(r2.ip);
49
  setIpCandidates(Array.isArray(r2.candidates) ? r2.candidates : []);
src/Controller.jsx CHANGED
@@ -1,9 +1,10 @@
1
  import React from 'react';
 
2
 
3
  const ROUND_MS = 60_000;
4
 
5
  function send(roomId, action) {
6
- fetch('/api/game/control', {
7
  method: 'POST',
8
  headers: { 'Content-Type': 'application/json' },
9
  body: JSON.stringify({ roomId, action }),
@@ -28,7 +29,7 @@ export default function Controller() {
28
 
29
  React.useEffect(() => {
30
  if (!roomId) return;
31
- const es = new EventSource(`/api/game/controller-events/${roomId}`);
32
  es.addEventListener('ready', (e) => {
33
  try { setPaired(JSON.parse(e.data).paired); } catch {}
34
  });
 
1
  import React from 'react';
2
+ import { api } from './api.js';
3
 
4
  const ROUND_MS = 60_000;
5
 
6
  function send(roomId, action) {
7
+ fetch(api('/api/game/control'), {
8
  method: 'POST',
9
  headers: { 'Content-Type': 'application/json' },
10
  body: JSON.stringify({ roomId, action }),
 
29
 
30
  React.useEffect(() => {
31
  if (!roomId) return;
32
+ const es = new EventSource(api(`/api/game/controller-events/${roomId}`));
33
  es.addEventListener('ready', (e) => {
34
  try { setPaired(JSON.parse(e.data).paired); } catch {}
35
  });
src/Game.jsx CHANGED
@@ -1,4 +1,5 @@
1
  import React from 'react';
 
2
 
3
  const GAME_W = 640;
4
  const GAME_H = 380;
@@ -15,7 +16,7 @@ const OMELET_TTL = 5500;
15
 
16
  function postState(roomId, payload) {
17
  if (!roomId) return;
18
- fetch('/api/game/state', {
19
  method: 'POST',
20
  headers: { 'Content-Type': 'application/json' },
21
  body: JSON.stringify({ roomId, ...payload }),
@@ -57,7 +58,7 @@ export default function Game({ username, onGameOver, roomId, lanIp, ipCandidates
57
  // SSE — receive controller events (left/right/start)
58
  React.useEffect(() => {
59
  if (!roomId) return;
60
- const es = new EventSource(`/api/game/events/${roomId}`);
61
  es.addEventListener('ready', (e) => {
62
  try { setPaired(!!JSON.parse(e.data).paired); } catch {}
63
  });
@@ -111,7 +112,7 @@ export default function Game({ username, onGameOver, roomId, lanIp, ipCandidates
111
  stateRef.current.running = false;
112
  setRunning(false);
113
  try {
114
- const res = await fetch('/api/score', {
115
  method: 'POST',
116
  headers: { 'Content-Type': 'application/json' },
117
  body: JSON.stringify({ username, score: finalScore }),
 
1
  import React from 'react';
2
+ import { api } from './api.js';
3
 
4
  const GAME_W = 640;
5
  const GAME_H = 380;
 
16
 
17
  function postState(roomId, payload) {
18
  if (!roomId) return;
19
+ fetch(api('/api/game/state'), {
20
  method: 'POST',
21
  headers: { 'Content-Type': 'application/json' },
22
  body: JSON.stringify({ roomId, ...payload }),
 
58
  // SSE — receive controller events (left/right/start)
59
  React.useEffect(() => {
60
  if (!roomId) return;
61
+ const es = new EventSource(api(`/api/game/events/${roomId}`));
62
  es.addEventListener('ready', (e) => {
63
  try { setPaired(!!JSON.parse(e.data).paired); } catch {}
64
  });
 
112
  stateRef.current.running = false;
113
  setRunning(false);
114
  try {
115
+ const res = await fetch(api('/api/score'), {
116
  method: 'POST',
117
  headers: { 'Content-Type': 'application/json' },
118
  body: JSON.stringify({ username, score: finalScore }),
src/Leaderboard.jsx CHANGED
@@ -1,4 +1,5 @@
1
  import React from 'react';
 
2
 
3
  export default function Leaderboard({ username, lastResult, onPlayAgain, onChangeUser }) {
4
  const [data, setData] = React.useState(null);
@@ -9,7 +10,7 @@ export default function Leaderboard({ username, lastResult, onPlayAgain, onChang
9
  let alive = true;
10
  (async () => {
11
  try {
12
- const r = await fetch(`/api/leaderboard?limit=25&username=${encodeURIComponent(username)}`)
13
  .then((r) => r.json());
14
  if (!alive) return;
15
  if (!r.ok) throw new Error(r.error || 'failed');
 
1
  import React from 'react';
2
+ import { api } from './api.js';
3
 
4
  export default function Leaderboard({ username, lastResult, onPlayAgain, onChangeUser }) {
5
  const [data, setData] = React.useState(null);
 
10
  let alive = true;
11
  (async () => {
12
  try {
13
+ const r = await fetch(api(`/api/leaderboard?limit=25&username=${encodeURIComponent(username)}`))
14
  .then((r) => r.json());
15
  if (!alive) return;
16
  if (!r.ok) throw new Error(r.error || 'failed');
src/MobileGame.jsx CHANGED
@@ -1,4 +1,5 @@
1
  import React from 'react';
 
2
 
3
  const GAME_W = 640;
4
  const GAME_H = 380;
@@ -45,7 +46,7 @@ export default function MobileGame({ username, onGameOver }) {
45
  stateRef.current.running = false;
46
  setRunning(false);
47
  try {
48
- const res = await fetch('/api/score', {
49
  method: 'POST',
50
  headers: { 'Content-Type': 'application/json' },
51
  body: JSON.stringify({ username, score: finalScore }),
 
1
  import React from 'react';
2
+ import { api } from './api.js';
3
 
4
  const GAME_W = 640;
5
  const GAME_H = 380;
 
46
  stateRef.current.running = false;
47
  setRunning(false);
48
  try {
49
+ const res = await fetch(api('/api/score'), {
50
  method: 'POST',
51
  headers: { 'Content-Type': 'application/json' },
52
  body: JSON.stringify({ username, score: finalScore }),
src/UsernameGate.jsx CHANGED
@@ -1,4 +1,5 @@
1
  import React from 'react';
 
2
 
3
  export default function UsernameGate({ onPick, isMobile }) {
4
  const [value, setValue] = React.useState('');
@@ -21,7 +22,7 @@ export default function UsernameGate({ onPick, isMobile }) {
21
  setBusy(true);
22
  setError('');
23
  try {
24
- const r = await fetch('/api/user/check', {
25
  method: 'POST',
26
  headers: { 'Content-Type': 'application/json' },
27
  body: JSON.stringify({ username: name }),
 
1
  import React from 'react';
2
+ import { api } from './api.js';
3
 
4
  export default function UsernameGate({ onPick, isMobile }) {
5
  const [value, setValue] = React.useState('');
 
22
  setBusy(true);
23
  setError('');
24
  try {
25
+ const r = await fetch(api('/api/user/check'), {
26
  method: 'POST',
27
  headers: { 'Content-Type': 'application/json' },
28
  body: JSON.stringify({ username: name }),
src/api.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ // Resolve API base URL.
2
+ // - Dev: empty string → relative /api/* hits Vite's proxy to localhost:5174
3
+ // - All-in-one prod: empty string → relative /api/* hits the same Express server that serves dist/
4
+ // - Split prod (frontend on Vercel, backend on HF Space): set VITE_API_BASE=https://<space>.hf.space at build time
5
+ const RAW = import.meta.env.VITE_API_BASE || '';
6
+ export const API_BASE = RAW.replace(/\/+$/, '');
7
+
8
+ export function api(path) {
9
+ return API_BASE + path;
10
+ }
vercel.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "buildCommand": "npm run build",
4
+ "outputDirectory": "dist",
5
+ "framework": "vite",
6
+ "rewrites": [
7
+ { "source": "/controller", "destination": "/index.html" }
8
+ ]
9
+ }