diff --git a/README.md b/README.md index d180264..9cebbce 100644 --- a/README.md +++ b/README.md @@ -20,25 +20,9 @@ This is a React implementation of the [example application](https://atproto.com/ # Install dependencies pnpm install -# Option 1: Local development (login won't work due to OAuth requirements) pnpm dev - -# Option 2: Development with OAuth login support (recommended) -pnpm dev:oauth ``` -### OAuth Development - -Due to OAuth requirements, HTTPS is needed for development. We've made this easy: - -- `pnpm dev:oauth` - Sets up everything automatically: - 1. Starts ngrok to create an HTTPS tunnel - 2. Configures environment variables with the ngrok URL - 3. Starts both the API server and client app - 4. Handles proper shutdown of all processes - -This all-in-one command makes OAuth development seamless. - ### Additional Commands ```bash @@ -89,25 +73,17 @@ This simplifies deployment to a single process that handles both the API and ser ## Environment Variables -Create a `.env` file in the root directory with: +Copy the `.env.template` file in the appview to `.env`: ``` -# Required for AT Protocol authentication -ATP_SERVICE_DID=did:plc:your-service-did -ATP_CLIENT_ID=your-client-id -ATP_CLIENT_SECRET=your-client-secret -ATP_REDIRECT_URI=https://your-domain.com/oauth-callback - -# Optional -PORT=3001 -SESSION_SECRET=your-session-secret +cd packages/appview +cp .env.template .env ``` ## Requirements - Node.js 18+ - pnpm 9+ -- ngrok (for OAuth development) ## License diff --git a/package.json b/package.json index 98f8d2e..519ce05 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "license": "MIT", "private": true, "scripts": { - "dev": "concurrently \"pnpm --filter @statusphere/appview dev\" \"pnpm --filter @statusphere/client dev\"", + "dev": "pnpm lexgen && concurrently \"pnpm dev:appview\" \"pnpm dev:client\"", + "dev:lexicon": "pnpm --filter @statusphere/lexicon dev", "dev:appview": "pnpm --filter @statusphere/appview dev", "dev:client": "pnpm --filter @statusphere/client dev", - "dev:oauth": "node scripts/setup-ngrok.js", - "lexgen": "pnpm --filter @statusphere/lexicon build", + "lexgen": "pnpm --filter @statusphere/lexicon lexgen", "build": "pnpm build:lexicon && pnpm build:client && pnpm build:appview", "build:lexicon": "pnpm --filter @statusphere/lexicon build", "build:appview": "pnpm --filter @statusphere/appview build", diff --git a/packages/appview/.env.template b/packages/appview/.env.template new file mode 100644 index 0000000..2c1466d --- /dev/null +++ b/packages/appview/.env.template @@ -0,0 +1,11 @@ +# Environment Configuration +NODE_ENV="development" # Options: 'development', 'production' +PORT="3001" # The port your server will listen on +VITE_PORT="3000" # The port the vite dev server is on (dev only) +HOST="127.0.0.1" # Hostname for the server +PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id. +DB_PATH=":memory:" # The SQLite database path. Leave as ":memory:" to use a temporary in-memory database. + +# Secrets +# Must set this in production. May be generated with `openssl rand -base64 33` +# COOKIE_SECRET="" diff --git a/packages/appview/src/auth/client.ts b/packages/appview/src/auth/client.ts index df857a6..e41c2e7 100644 --- a/packages/appview/src/auth/client.ts +++ b/packages/appview/src/auth/client.ts @@ -5,29 +5,22 @@ import { env } from '#/lib/env' import { SessionStore, StateStore } from './storage' export const createClient = async (db: Database) => { - // Get the ngrok URL from environment variables - const ngrokUrl = env.NGROK_URL - - if (!ngrokUrl && env.NODE_ENV === 'development') { - console.warn( - 'WARNING: NGROK_URL is not set. OAuth login might not work properly.', - ) - console.warn( - 'You should run ngrok and set the NGROK_URL environment variable.', - ) - console.warn('Example: NGROK_URL=https://abcd-123-45-678-90.ngrok.io') - } else if (env.NODE_ENV === 'production' && !env.PUBLIC_URL) { + if (env.isProduction && !env.PUBLIC_URL) { throw new Error('PUBLIC_URL is not set') } - const baseUrl = ngrokUrl || env.PUBLIC_URL || `http://127.0.0.1:${env.PORT}` + const publicUrl = env.PUBLIC_URL + const url = publicUrl || `http://127.0.0.1:${env.VITE_PORT}` + const enc = encodeURIComponent return new NodeOAuthClient({ clientMetadata: { client_name: 'Statusphere React App', - client_id: `${baseUrl}/api/client-metadata.json`, - client_uri: baseUrl, - redirect_uris: [`${baseUrl}/api/oauth/callback`], + client_id: publicUrl + ? `${url}/api/client-metadata.json` + : `http://localhost?redirect_uri=${enc(`${url}/api/oauth/callback`)}&scope=${enc('atproto transition:generic')}`, + client_uri: url, + redirect_uris: [`${url}/api/oauth/callback`], scope: 'atproto transition:generic', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], diff --git a/packages/appview/src/index.ts b/packages/appview/src/index.ts index 2c09993..93b5304 100644 --- a/packages/appview/src/index.ts +++ b/packages/appview/src/index.ts @@ -79,22 +79,8 @@ export class Server { 'http://127.0.0.1:3000', // Alternative React address ] - // If we have an ngrok URL defined, add it to allowed origins - if (env.NGROK_URL) { - try { - const ngrokOrigin = new URL(env.NGROK_URL) - const ngrokClientOrigin = `${ngrokOrigin.protocol}//${ngrokOrigin.hostname}:3000` - allowedOrigins.push(ngrokClientOrigin) - } catch (err) { - console.error('Failed to parse NGROK_URL for CORS:', err) - } - } - // Check if the request origin is in our allowed list or is an ngrok domain - if ( - allowedOrigins.indexOf(origin) !== -1 || - origin.includes('ngrok-free.app') - ) { + if (allowedOrigins.indexOf(origin) !== -1) { callback(null, true) } else { console.warn(`⚠️ CORS blocked origin: ${origin}`) @@ -122,42 +108,42 @@ export class Server { app.use(express.json()) app.use(express.urlencoded({ extended: true })) - // Two versions of the API routes: - // 1. Mounted at /api for the client app.use('/api', router) - // Serve static files from the frontend build - const frontendPath = path.resolve( - __dirname, - '../../../packages/client/dist', - ) - - // Check if the frontend build exists - if (fs.existsSync(frontendPath)) { - logger.info(`Serving frontend static files from: ${frontendPath}`) - - // Serve static files - app.use(express.static(frontendPath)) - - // Heathcheck - app.get('/health', (req, res) => { - res.status(200).json({ status: 'ok' }) - }) + // Serve static files from the frontend build - prod only + if (env.isProduction) { + const frontendPath = path.resolve( + __dirname, + '../../../packages/client/dist', + ) - // For any other requests, send the index.html file - app.get('*', (req, res) => { - // Only handle non-API paths - if (!req.path.startsWith('/api/')) { - res.sendFile(path.join(frontendPath, 'index.html')) - } else { - res.status(404).json({ error: 'API endpoint not found' }) - } - }) - } else { - logger.warn(`Frontend build not found at: ${frontendPath}`) - app.use('*', (_req, res) => { - res.sendStatus(404) - }) + // Check if the frontend build exists + if (fs.existsSync(frontendPath)) { + logger.info(`Serving frontend static files from: ${frontendPath}`) + + // Serve static files + app.use(express.static(frontendPath)) + + // Heathcheck + app.get('/health', (req, res) => { + res.status(200).json({ status: 'ok' }) + }) + + // For any other requests, send the index.html file + app.get('*', (req, res) => { + // Only handle non-API paths + if (!req.path.startsWith('/api/')) { + res.sendFile(path.join(frontendPath, 'index.html')) + } else { + res.status(404).json({ error: 'API endpoint not found' }) + } + }) + } else { + logger.warn(`Frontend build not found at: ${frontendPath}`) + app.use('*', (_req, res) => { + res.sendStatus(404) + }) + } } // Use the port from env (should be 3001 for the API server) diff --git a/packages/appview/src/lib/env.ts b/packages/appview/src/lib/env.ts index 01b0099..09e6164 100644 --- a/packages/appview/src/lib/env.ts +++ b/packages/appview/src/lib/env.ts @@ -1,5 +1,5 @@ import dotenv from 'dotenv' -import { cleanEnv, host, port, str, testOnly, url } from 'envalid' +import { cleanEnv, host, port, str, testOnly } from 'envalid' dotenv.config() @@ -8,12 +8,11 @@ export const env = cleanEnv(process.env, { devDefault: testOnly('test'), choices: ['development', 'production', 'test'], }), - HOST: host({ devDefault: testOnly('localhost') }), - PORT: port({ devDefault: testOnly(3001) }), + HOST: host({ devDefault: '127.0.0.1' }), + PORT: port({ devDefault: 3001 }), + VITE_PORT: port({ devDefault: 3000 }), DB_PATH: str({ devDefault: ':memory:' }), - COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }), - ATPROTO_SERVER: str({ default: 'https://bsky.social' }), + COOKIE_SECRET: str({ devDefault: '0'.repeat(32) }), SERVICE_DID: str({ default: undefined }), - PUBLIC_URL: str({ default: 'http://localhost:3001' }), - NGROK_URL: str({ default: '' }), + PUBLIC_URL: str({ devDefault: '' }), }) diff --git a/packages/appview/src/routes.ts b/packages/appview/src/routes.ts index a78f4ec..492334b 100644 --- a/packages/appview/src/routes.ts +++ b/packages/appview/src/routes.ts @@ -238,7 +238,7 @@ export const createRouter = (ctx: AppContext) => { .selectFrom('status') .selectAll() .orderBy('indexedAt', 'desc') - .limit(10) + .limit(30) .execute() res.json({ diff --git a/packages/client/src/components/StatusList.tsx b/packages/client/src/components/StatusList.tsx index c77c1ce..8dd16f9 100644 --- a/packages/client/src/components/StatusList.tsx +++ b/packages/client/src/components/StatusList.tsx @@ -4,7 +4,7 @@ import api from '#/services/api' const StatusList = () => { // Use React Query to fetch and cache statuses - const { data, isLoading, isError, error } = useQuery({ + const { data, isPending, isError, error } = useQuery({ queryKey: ['statuses'], queryFn: async () => { const data = await api.getStatuses() @@ -17,7 +17,7 @@ const StatusList = () => { // Destructure data const statuses = data?.statuses || [] - if (isLoading && !data) { + if (isPending && !data) { return (