Drops the /flash and /html5 sections (old URLs redirect to /browse), the GitHub star count endpoint, the unused HOME_RECENT_HOURS setting, upstream's design notes and code review docs, and the issue template's link to upstream discussions.
34 KiB
34 KiB
CLAUDE.md
Project Overview
Dotcom Arcade: a self-hosted, Flash-era game portal for Flashpoint Ultimate. Built on darkraise's flashpoint-web.
- Backend (Express/TypeScript, port 3100): REST API + game content via
/game-proxy/*,/game-zip/* - Frontend (Vite/React/TypeScript, port 5173): TanStack Query, Zustand, Tailwind, Ruffle
- Databases:
flashpoint.sqlite(read-only, never write) +user.db(app data) - Streaming (
streaming/): a session manager and per-player streamer containers for games that can't run in a browser; seestreaming/README.md
Commands
npm run install:all # Install all
npm run dev # Start all services
npm run typecheck # Type check all
npm run build # Build all
npm test # Run backend tests
Documentation
Docs in docs/ (100+ files). Reference docs instead of repeating:
| Topic | Path |
|---|---|
| Architecture | docs/02-architecture/system-architecture.md |
| API Reference | docs/06-api-reference/README.md |
| Common Pitfalls | docs/08-development/common-pitfalls.md |
| Setup Guide | docs/08-development/setup-guide.md |
| Environment Vars | docs/09-deployment/environment-variables.md |
| Database Schema | docs/12-reference/database-schema-reference.md |
| Game Service | docs/05-game-service/architecture.md |
| Components | docs/04-frontend/components/component-overview.md |
Coding Standards (MANDATORY)
Follow these rules strictly. Violations will be caught in code review.
TypeScript & Types
- No
anytypes — useunknown, generics, or proper interfaces - No non-null assertions (
!) — use?? defaultValueor proper null checks - Use
??not||for defaults —||treats0,"",falseas falsy - Validate
parseInt()— always checkisNaN(), NaN silently passes to SQL - Throw
AppErrornot plainError— ensures proper HTTP status codes - Define return types for exported functions — improves type inference
- Use
readonlyarrays when mutation isn't needed —readonly string[] - Prefer
unknownoveranyfor caught errors —catch (error: unknown)
// WRONG
const limit = parseInt(req.query.limit) || 10; // NaN becomes 10, but "0" also becomes 10
const user = users.find((u) => u.id === id)!; // Crashes if not found
// CORRECT
const parsed = parseInt(req.query.limit as string, 10);
const limit = isNaN(parsed) ? 10 : parsed;
const user = users.find((u) => u.id === id) ?? null;
Express Backend
asyncHandler()on ALL async route handlers — prevents unhandled promise rejections- Static routes BEFORE parameterized —
/randommust come before/:id - Check
res.headersSentbefore error responses — required for SSE/streaming - Cap query limits —
Math.min(parsedLimit, 100)prevents data dumps - Add lower bounds too —
Math.max(1, Math.min(parsedLimit, 100)) - Validate query params — use Zod schemas, check bounds (min/max)
- Wrap check-then-insert in
db.transaction()— prevents TOCTOU race conditions - Strip port from hostname —
req.headers.host?.split(':')[0] - Return early on validation failure — don't continue processing bad input
- Use
requirePermission()middleware — for protected endpoints
// WRONG - async handler without wrapper
router.get('/games', async (req, res) => { ... });
// WRONG - parameterized route shadows static
router.get('/:id', ...);
router.get('/random', ...); // Never reached!
// CORRECT
router.get('/random', asyncHandler(async (req, res) => { ... }));
router.get('/:id', asyncHandler(async (req, res) => { ... }));
Database
- Never write to
flashpoint.sqlite— it's read-only, managed by Flashpoint Launcher - Use transactions for multi-step operations — atomic success or rollback
- Batch large operations — wrap all batches in single outer transaction
- Use scalar subqueries or window functions — avoid separate COUNT queries
- Build Maps for O(1) lookups — never use
.find()in loops over large datasets - Always use parameterized queries — never string interpolation for SQL
// WRONG - O(N²) complexity
const results = games.map((game) => {
const favorite = favorites.find((f) => f.gameId === game.id); // Scans entire array
return { ...game, isFavorite: !!favorite };
});
// CORRECT - O(N) with Map
const favoriteSet = new Set(favorites.map((f) => f.gameId));
const results = games.map((game) => ({
...game,
isFavorite: favoriteSet.has(game.id),
}));
// WRONG - separate COUNT query
const games = db.all('SELECT * FROM game LIMIT ?', [limit]);
const total = db.get('SELECT COUNT(*) as count FROM game').count;
// CORRECT - window function
const games = db.all(
`
SELECT *, COUNT(*) OVER() as total_count
FROM game LIMIT ?
`,
[limit]
);
React Frontend
- All API calls through
@/lib/api.ts— never rawfetch(), bypasses auth/refresh - Use
@/imports — not relative../paths - Use theme tokens —
text-muted-foregroundnottext-gray-400 - Store callbacks in
useRefwhen used inuseEffect— prevents infinite loops - Store timeouts/intervals in
useRef— notletvariables, prevents stale closures - Content-based keys — never array indices for dynamic/removable items
- Use
logger— notconsole.log/error/debug - Return cleanup from
useEffect— especially for Image preloads, timers, subscriptions - Guard StrictMode double-mount — use
isMountedRef+ delay for session tracking - Complete
memocomparators — include ALL props that affect rendering - Use
useCallbackfor event handlers passed to memoized children
// WRONG - callback in useEffect deps causes infinite loop
useEffect(() => {
fetchData().then(onSuccess);
}, [onSuccess]); // onSuccess is new function each render!
// CORRECT - store callback in ref
const onSuccessRef = useRef(onSuccess);
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
useEffect(() => {
fetchData().then(data => onSuccessRef.current?.(data));
}, []);
// WRONG - index as key
{items.map((item, i) => <Item key={i} {...item} />)}
// CORRECT - content-based key
{items.map(item => <Item key={item.id} {...item} />)}
// WRONG - hardcoded colors
<p className="text-gray-400">Muted text</p>
// CORRECT - theme tokens
<p className="text-muted-foreground">Muted text</p>
Resource Management
- Add
.catch()to fire-and-forget promises — prevents unhandled rejections - Add
.unref()to cleanup intervals — allows graceful Node.js shutdown - Handle
res.on('close')— destroy streams on client disconnect - Close resources in catch blocks — ZIP handles, file descriptors, DB connections
- Track
closedstate for SSE — check before everyres.write()and after awaits - Cancel pending timers on cleanup — track timer IDs, clear in
finally/cleanup - Use
shuttingDownflag — prevent new resource acquisition during shutdown - Await mount operations — never fire-and-forget ZIP mounts
// WRONG - unhandled rejection
downloadInBackground(gameId);
// CORRECT
downloadInBackground(gameId).catch((err) =>
logger.error('Download failed:', err)
);
// WRONG - interval keeps process alive
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
// CORRECT
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
this.cleanupInterval.unref();
// WRONG - write after client disconnect
res.write(`data: ${JSON.stringify(update)}\n\n`);
// CORRECT
let closed = false;
req.on('close', () => {
closed = true;
});
if (!closed) {
res.write(`data: ${JSON.stringify(update)}\n\n`);
}
Security
- Allowlists over denylists — for domains, CGI env vars, file extensions
- Validate resolved paths —
path.resolve()+startsWith(baseDir)prevents traversal - Resolve symlinks first —
fs.realpath()before path validation - Use
URLconstructor — not string concatenation for URL building encodeURIComponent()for URL params — handles special characters- Pre-check DNS resolution — prevents SSRF via DNS rebinding
- Verify checksums — for downloaded files, throw on verification failure
- Re-validate after async operations — file paths after download, tokens after refresh
- Guest users (id=0) have restricted permissions —
games.read,playlists.read, andgames.playonly; no writes
// WRONG - path traversal vulnerability
const filePath = path.join(baseDir, req.params.filename);
return fs.readFile(filePath);
// CORRECT
const filePath = path.resolve(baseDir, req.params.filename);
const realPath = await fs.realpath(filePath);
if (!realPath.startsWith(baseDir)) {
throw new AppError(403, 'Access denied');
}
return fs.readFile(realPath);
// WRONG - string concatenation for URLs
const url = baseUrl + '/' + filename;
// CORRECT
const url = new URL(filename, baseUrl).href;
Error Handling
- Only cache ENOENT — never cache transient errors (negative cache poisoning)
- Prevent double Promise settlement — use
settledflag in event handlers - Differentiate error types — JSON parse errors vs ENOENT in config loading
- Log errors with context — include IDs, paths, operation names
- Use
AppErrorwith appropriate status codes — 400 for validation, 404 for not found, 500 for internal
// WRONG - caches all errors
try {
return await fs.readFile(path);
} catch {
this.notFoundCache.set(path, true); // Caches permission errors too!
}
// CORRECT - only cache definitive "not found"
try {
return await fs.readFile(path);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
this.notFoundCache.set(path, true);
}
throw error;
}
// WRONG - double settlement possible
return new Promise((resolve, reject) => {
req.on('end', () => resolve(body));
req.on('error', reject);
});
// CORRECT
return new Promise((resolve, reject) => {
let settled = false;
req.on('end', () => {
if (!settled) {
settled = true;
resolve(body);
}
});
req.on('error', (err) => {
if (!settled) {
settled = true;
reject(err);
}
});
});
Code Quality
- Don't export internal constants — reduces public API surface
- Don't duplicate utilities — consolidate to single source of truth
[...arr].sort()— notarr.sort(), avoid mutation- Use
switchfor mutually exclusive conditions — not overlappingifblocks - Remove unused imports/variables — keep code clean
- Consistent naming — camelCase for variables, PascalCase for components/types
- Add
displayNameto HOC-wrapped components — improves debugging - Prefer early returns — reduces nesting, improves readability
// WRONG - overlapping if blocks
if (!cacheType || cacheType === 'games') clearGamesCache();
if (!cacheType || cacheType === 'users') clearUsersCache();
// CORRECT - mutually exclusive
switch (cacheType) {
case 'games':
clearGamesCache();
break;
case 'users':
clearUsersCache();
break;
default:
clearGamesCache();
clearUsersCache();
}
// WRONG - mutates original array
const sorted = tags.sort();
// CORRECT - creates new array
const sorted = [...tags].sort();
Zod Validation
- Align frontend/backend schemas — same limits, same required fields
- Use
.min()and.max()— validate string lengths, number ranges - Use
.url()for URLs — validates format - Use enums for known values —
z.enum(['option1', 'option2']) - Validate optional fields properly —
z.string().optional()notz.string()
// Backend schema
const createPlaylistSchema = z.object({
title: z.string().min(1).max(100),
description: z.string().max(500).optional(),
gameIds: z.array(z.string().uuid()).max(100),
});
// Frontend schema should match
const playlistSchema = z.object({
title: z.string().min(1, 'Title required').max(100, 'Title too long'),
description: z.string().max(500).optional(),
gameIds: z.array(z.string()).max(100),
});
Project-Specific Patterns
API Layer Pattern
All frontend API calls must go through frontend/src/lib/api.ts:
// In api.ts - add new endpoint
export const playlistsApi = {
getAll: async (params?: PaginationParams) => {
const { data } = await api.get<ApiResponse<Playlist[]>>('/playlists', {
params,
});
return data;
},
create: async (data: CreatePlaylistData) => {
const { data: response } = await api.post<ApiResponse<Playlist>>(
'/playlists',
data
);
return response.data;
},
};
// In component - use the API
const { data, isLoading } = useQuery({
queryKey: ['playlists'],
queryFn: () => playlistsApi.getAll(),
});
Route Handler Pattern
// backend/src/routes/example.ts
import { Router } from 'express';
import { asyncHandler } from '../middleware/asyncHandler';
import { authenticate, requirePermission } from '../middleware/auth';
import { validate } from '../middleware/validation';
import { z } from 'zod';
const router = Router();
const createSchema = z.object({
body: z.object({
name: z.string().min(1).max(100),
value: z.number().int().min(0).max(1000),
}),
});
// Static routes first
router.get(
'/stats',
authenticate,
requirePermission('example.read'),
asyncHandler(async (req, res) => {
const stats = await ExampleService.getStats();
res.json({ success: true, data: stats });
})
);
// Parameterized routes after
router.get(
'/:id',
authenticate,
asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
throw new AppError(400, 'Invalid ID');
}
const item = await ExampleService.getById(id);
if (!item) {
throw new AppError(404, 'Item not found');
}
res.json({ success: true, data: item });
})
);
router.post(
'/',
authenticate,
requirePermission('example.create'),
validate(createSchema),
asyncHandler(async (req, res) => {
const item = await ExampleService.create(req.body, req.user!.id);
res.status(201).json({ success: true, data: item });
})
);
export default router;
Service Pattern
// backend/src/services/ExampleService.ts
import { UserDatabaseService } from './UserDatabaseService';
import { AppError } from '../middleware/errorHandler';
export class ExampleService {
static getById(id: number): Example | null {
return (
UserDatabaseService.get<Example>('SELECT * FROM examples WHERE id = ?', [
id,
]) ?? null
);
}
static create(data: CreateExampleData, userId: number): Example {
const db = UserDatabaseService.getDatabase();
// Use transaction for check-then-insert
return db.transaction(() => {
const existing = UserDatabaseService.get<{ id: number }>(
'SELECT id FROM examples WHERE name = ?',
[data.name]
);
if (existing) {
throw new AppError(409, 'Name already exists');
}
const result = UserDatabaseService.run(
'INSERT INTO examples (name, value, created_by) VALUES (?, ?, ?)',
[data.name, data.value, userId]
);
return this.getById(result.lastInsertRowid as number)!;
})();
}
}
React Query Hook Pattern
// frontend/src/hooks/useExample.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { exampleApi } from '@/lib/api';
import { toast } from 'sonner';
export function useExamples(params?: PaginationParams) {
return useQuery({
queryKey: ['examples', params],
queryFn: () => exampleApi.getAll(params),
});
}
export function useExample(id: string | undefined) {
return useQuery({
queryKey: ['examples', id],
queryFn: () => exampleApi.getById(id!),
enabled: !!id,
});
}
export function useCreateExample() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: exampleApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['examples'] });
toast.success('Example created');
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
}
Component Pattern
// frontend/src/components/example/ExampleCard.tsx
import { memo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface ExampleCardProps {
example: Example;
isSelected?: boolean;
onClick?: (example: Example) => void;
className?: string;
}
export const ExampleCard = memo(function ExampleCard({
example,
isSelected,
onClick,
className,
}: ExampleCardProps) {
return (
<Card
className={cn(
'cursor-pointer transition-colors hover:bg-muted/50',
isSelected && 'border-primary',
className
)}
onClick={() => onClick?.(example)}
>
<CardHeader>
<CardTitle className="text-foreground">{example.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{example.description}</p>
</CardContent>
</Card>
);
}, (prevProps, nextProps) => {
// Custom comparator - include ALL props that affect rendering
return (
prevProps.example.id === nextProps.example.id &&
prevProps.example.name === nextProps.example.name &&
prevProps.example.description === nextProps.example.description &&
prevProps.isSelected === nextProps.isSelected &&
prevProps.className === nextProps.className
// Note: onClick is stable if parent uses useCallback
);
});
ExampleCard.displayName = 'ExampleCard';
Game Service Patterns
// Mounting a game ZIP - always await
const result = await gameZipServer.mountGameZip(
gameId,
zipPath,
dateAdded,
sha256
);
if (!result.success) {
throw new AppError(500, `Failed to mount: ${result.error}`);
}
// Checking download status
const isDownloading = DownloadRegistry.isDownloading(gameId);
const activeCount = DownloadRegistry.getActiveCount();
// Capacity check - include both download systems
const totalActive = localDownloads.size + DownloadRegistry.getActiveCount();
if (totalActive >= MAX_CONCURRENT_DOWNLOADS) {
throw new AppError(503, 'Download capacity reached');
}
// File serving from ZIP - check entry exists first
const entries = zipManager.getEntries();
if (!entries.has(requestedFile)) {
return res.status(404).send('File not found');
}
// Only then read the file
const data = await zipManager.readFile(requestedFile);
SSE (Server-Sent Events) Pattern
router.get(
'/stream',
authenticate,
asyncHandler(async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
let closed = false;
req.on('close', () => {
closed = true;
// Clean up subscriptions, intervals, etc.
});
const sendEvent = (data: unknown) => {
if (closed || res.headersSent === false) return;
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
} catch {
closed = true;
}
};
// Send initial data
sendEvent({ type: 'connected' });
// Set up interval (with cleanup)
const interval = setInterval(() => {
if (closed) {
clearInterval(interval);
return;
}
sendEvent({ type: 'heartbeat', timestamp: Date.now() });
}, 30000);
// Clean up on close
req.on('close', () => clearInterval(interval));
})
);
Download Polling Pattern (Frontend)
const { data: launchData, isLoading } = useQuery({
queryKey: ['game', gameId, 'launch'],
queryFn: () => gamesApi.getLaunchData(gameId),
// Conditional polling - only when downloading
refetchInterval: (query) => {
return query.state.data?.downloading ? 2000 : false;
},
});
// Render based on state
if (isLoading) return <LoadingSpinner />;
if (launchData?.downloading) return <DownloadProgress gameId={gameId} />;
if (launchData?.mounted) return <GamePlayer gameId={gameId} />;
Architecture Quick Reference
Request Flow
Frontend Request
↓
Vite Dev Proxy (dev) / Nginx (prod)
↓
Express Server (port 3100)
↓
Middleware Chain:
1. CORS
2. Helmet (security headers)
3. Rate limiting
4. Body parsing
5. Authentication (JWT)
6. Permission check (RBAC)
↓
Route Handler
↓
Service Layer
↓
Database (SQLite)
Game Play Flow
1. User clicks "Play"
↓
2. Frontend: GET /api/games/:id/launch
↓
3. Backend: Check if ZIP mounted
↓
4a. If mounted → return { mounted: true, launchCommand }
4b. If not mounted → start download, return { downloading: true }
↓
5. Frontend polls every 2s while downloading
↓
6. When ready → render GamePlayer component
↓
7. GamePlayer loads game via /game-proxy/* or /game-zip/*
Key Architecture Points
- Game routes registered BEFORE auth middleware —
/game-proxy/*,/game-zip/*need cross-origin access - JWT in memory, refresh token in HTTP-only cookie —
fp_refresh, path/api/auth - Permissions cached 5min via
PermissionCache— maintenance mode blocks non-admins (503) - Database hot-reload —
DatabaseServicewatches flashpoint.sqlite for Launcher changes - Two download systems —
DownloadManagerfor metadata,GameZipServerfor game files - LRU cache for ZIP handles — prevents file descriptor exhaustion
File Structure
backend/
├── src/
│ ├── routes/ # Express route handlers
│ ├── services/ # Business logic
│ ├── middleware/ # Express middleware
│ ├── game/ # Game content serving
│ │ ├── gamezipserver.ts
│ │ ├── zip-manager.ts
│ │ ├── legacy-server.ts
│ │ └── proxy-request-handler.ts
│ ├── types/ # TypeScript types
│ └── utils/ # Utility functions
frontend/
├── src/
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── common/ # Shared components
│ │ ├── layout/ # Layout components
│ │ └── [feature]/ # Feature-specific
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities, API client
│ ├── store/ # Zustand stores
│ ├── types/ # TypeScript types
│ └── views/ # Page components
Common Theme Tokens
| Instead of | Use |
|---|---|
text-gray-900, text-white |
text-foreground |
text-gray-400, text-gray-500 |
text-muted-foreground |
bg-white, bg-gray-900 |
bg-background |
bg-gray-100, bg-gray-800 |
bg-muted |
bg-gray-50, bg-gray-850 |
bg-muted/50 |
border-gray-200, border-gray-700 |
border-border |
text-blue-600 |
text-primary |
bg-blue-600 |
bg-primary |
text-red-600 |
text-destructive |
bg-red-600 |
bg-destructive |
ring-blue-500 |
ring-ring |
Permission System
Permission Format
resource.action — e.g., games.read, users.create, settings.update
Common Permissions
| Permission | Description |
|---|---|
games.read |
View game list and details |
games.play |
Play games |
playlists.create |
Create playlists |
playlists.delete |
Delete own playlists |
users.read |
View user list |
users.create |
Create new users |
roles.read |
View roles |
roles.update |
Modify roles |
settings.read |
View settings |
settings.update |
Modify settings |
activities.read |
View activity logs |
Frontend Permission Check
// Using RoleGuard component
<RoleGuard permission="users.create">
<CreateUserButton />
</RoleGuard>
// Using hook
const { hasPermission } = useAuth();
if (hasPermission('settings.update')) {
// Show settings form
}
Backend Permission Check
router.post(
'/users',
authenticate,
requirePermission('users.create'),
asyncHandler(async (req, res) => { ... })
);
Error Response Format
All API errors should follow this format:
// Success response
{
"success": true,
"data": { ... }
}
// Error response
{
"success": false,
"error": "Human-readable error message"
}
// Paginated response
{
"success": true,
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5
}
}
Testing Patterns
Backend Unit Test
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ExampleService } from './ExampleService';
describe('ExampleService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getById', () => {
it('should return null for non-existent id', () => {
const result = ExampleService.getById(999);
expect(result).toBeNull();
});
it('should return example for valid id', () => {
const result = ExampleService.getById(1);
expect(result).toMatchObject({ id: 1 });
});
});
});
Frontend Component Test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExampleCard } from './ExampleCard';
describe('ExampleCard', () => {
const mockExample = { id: '1', name: 'Test', description: 'Desc' };
it('renders example name', () => {
render(<ExampleCard example={mockExample} />);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const onClick = vi.fn();
render(<ExampleCard example={mockExample} onClick={onClick} />);
await userEvent.click(screen.getByText('Test'));
expect(onClick).toHaveBeenCalledWith(mockExample);
});
});
Additional Rules & Anti-Patterns
Form Handling
- Reset form state on dialog close — clear errors, reset to defaults
- Disable submit while pending —
disabled={mutation.isPending} - Show loading state on buttons —
{isPending ? 'Saving...' : 'Save'} - Handle form submission errors — display in Alert, not just toast
- Validate on blur and submit — not just submit
// Dialog with proper form handling
function CreateDialog({ isOpen, onClose, onSuccess }) {
const mutation = useCreateExample();
const form = useForm({ resolver: zodResolver(schema) });
// Reset form when dialog closes
useEffect(() => {
if (!isOpen) {
form.reset();
mutation.reset();
}
}, [isOpen]);
const onSubmit = async (data) => {
try {
await mutation.mutateAsync(data);
onSuccess();
onClose();
} catch {
// Error displayed via mutation.error
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
{mutation.isError && (
<Alert variant="destructive">
{getErrorMessage(mutation.error)}
</Alert>
)}
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* fields */}
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create'}
</Button>
</form>
</Dialog>
);
}
State Management (Zustand)
- Use selectors —
useStore(state => state.value)notuseStore() - Avoid storing derived state — compute from source data
- Use
persistmiddleware carefully — don't persist sensitive data - Update state immutably — use spread or immer
// WRONG - subscribes to entire store, re-renders on any change
const { user, theme, sidebar } = useUIStore();
// CORRECT - only re-renders when specific value changes
const user = useAuthStore((state) => state.user);
const theme = useUIStore((state) => state.theme);
TanStack Query
- Include all dependencies in queryKey — for proper cache invalidation
- Use
enabledto prevent unnecessary fetches —enabled: !!id - Invalidate related queries on mutation — not just the direct one
- Use
staleTimefor rarely-changing data — reduces refetches - Handle loading and error states — don't just check
data
// WRONG - missing dependency in queryKey
useQuery({
queryKey: ['games'],
queryFn: () => getGames({ platform, library }), // platform/library not in key!
});
// CORRECT
useQuery({
queryKey: ['games', { platform, library }],
queryFn: () => getGames({ platform, library }),
});
// Invalidate related queries
useMutation({
mutationFn: deletePlaylist,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['playlists'] });
queryClient.invalidateQueries({ queryKey: ['playlist-count'] }); // Related!
},
});
Accessibility
- Add
aria-labelto icon-only buttons — screen reader support - Use semantic HTML —
<button>not<div onClick> - Ensure keyboard navigation — focus management in modals
- Provide alt text for images — meaningful descriptions
- Use
aria-hiddenfor decorative elements
// WRONG - no accessible name
<Button size="icon" onClick={onDelete}>
<Trash className="h-4 w-4" />
</Button>
// CORRECT
<Button size="icon" onClick={onDelete} aria-label="Delete item">
<Trash className="h-4 w-4" aria-hidden="true" />
</Button>
Performance
- Virtualize long lists — use
@tanstack/react-virtualfor 100+ items - Lazy load heavy components —
React.lazy()for routes, modals - Debounce search inputs — 300ms typical, use
useDebouncehook - Memoize expensive computations —
useMemofor filtering/sorting large arrays - Avoid inline object/array literals in props — creates new reference each render
// WRONG - new object every render, breaks memoization
<GameList filters={{ platform: 'Flash' }} />
// CORRECT - stable reference
const filters = useMemo(() => ({ platform: 'Flash' }), []);
<GameList filters={filters} />
// Debounced search
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const { data } = useQuery({
queryKey: ['games', debouncedSearch],
queryFn: () => searchGames(debouncedSearch),
});
Date/Time Handling
- Store dates as ISO strings —
new Date().toISOString() - Use
date-fnsfor formatting — not manual string manipulation - Handle timezones explicitly — store UTC, display local
- Validate date inputs — check for Invalid Date
// WRONG - locale-dependent, ambiguous
const dateStr = new Date().toLocaleDateString();
// CORRECT - ISO format for storage/API
const isoStr = new Date().toISOString();
// CORRECT - date-fns for display
import { format, formatDistanceToNow } from 'date-fns';
const display = format(new Date(isoStr), 'PPp'); // "Apr 29, 2024, 12:00 PM"
const relative = formatDistanceToNow(new Date(isoStr), { addSuffix: true });
Logging Best Practices
- Use appropriate log levels — debug for verbose, info for key events, warn for recoverable issues, error for failures
- Include context in logs — IDs, operation names, relevant data
- Don't log sensitive data — passwords, tokens, PII
- Use structured logging — objects not string concatenation
// WRONG - no context, string concat
logger.info('User logged in');
logger.error('Failed: ' + error.message);
// CORRECT - structured with context
logger.info({ userId: user.id, method: 'local' }, 'User logged in');
logger.error(
{ error, gameId, operation: 'mountZip' },
'Failed to mount game ZIP'
);
// WRONG - logs sensitive data
logger.debug({ password, token }, 'Auth attempt');
// CORRECT - omit sensitive fields
logger.debug({ username, method }, 'Auth attempt');
Import Organization
Organize imports in this order (enforced by Prettier):
- React/external libraries
- Internal aliases (
@/) - Relative imports
- Types (with
typekeyword)
// Correct import order
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth';
import { gamesApi } from '@/lib/api';
import { GameCard } from './GameCard';
import type { Game } from '@/types/game';
Code Review Checklist
Before submitting code, verify:
- No
anytypes - No
!non-null assertions - All async handlers wrapped with
asyncHandler() - All
parseInt()validated withisNaN() - Query limits capped with
Math.min() - Fire-and-forget promises have
.catch() - Cleanup intervals have
.unref() - useEffect returns cleanup function where needed
- Keys are content-based, not indices
- Theme tokens used instead of hardcoded colors
@/imports used instead of relative paths- No
console.log— uselogger - Forms reset on dialog close
- Loading and error states handled
- Prettier and typecheck pass
Before Committing
- Run Prettier:
npx prettier --write <files> - Run typecheck:
npm run typecheck - Run build:
npm run build - Run tests:
npm test(if applicable) - Ask user if docs need updating
Environment
FLASHPOINT_PATH=/path/to/flashpoint # Required - path to Flashpoint installation
JWT_SECRET=your-secret # Required - random string for JWT signing
DOMAIN=http://localhost:5173 # Optional - frontend URL for CORS
LOG_LEVEL=info # Optional - debug, info, warn, error