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 (
Loading statuses... diff --git a/packages/client/src/vite-env.d.ts b/packages/client/src/vite-env.d.ts deleted file mode 100644 index b54b4c9..0000000 --- a/packages/client/src/vite-env.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/// - -interface ImportMetaEnv { - readonly VITE_API_URL: string -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 1d15cab..522eac8 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,4 +1,4 @@ -import path from 'path' +import path from 'node:path' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' @@ -14,14 +14,12 @@ export default defineConfig({ tailwindcss(), ], server: { + host: '127.0.0.1', port: 3000, - // allow ngrok - allowedHosts: true, proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, diff --git a/packages/lexicon/package.json b/packages/lexicon/package.json index 65c5343..de3eebf 100644 --- a/packages/lexicon/package.json +++ b/packages/lexicon/package.json @@ -8,12 +8,12 @@ "types": "dist/index.d.ts", "private": true, "scripts": { - "build": "pnpm run lexgen && tsup", + "build": "pnpm lexgen && tsup", "dev": "tsup --watch", "clean": "rimraf dist", "typecheck": "tsc --noEmit", - "lexgen": "lex gen-api ./src ../../lexicons/xyz/statusphere/* ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* --yes", - "postinstall": "pnpm run build" + "lexgen": "lex gen-api ./src ../../lexicons/xyz/statusphere/* ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* --yes && pnpm format", + "format": "prettier --write src" }, "dependencies": { "@atproto/api": "^0.14.7", diff --git a/scripts/setup-ngrok.js b/scripts/setup-ngrok.js deleted file mode 100755 index e983908..0000000 --- a/scripts/setup-ngrok.js +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env node - -/** - * This script automatically sets up ngrok for development. - * It: - * 1. Starts ngrok to tunnel to localhost:3001 - * 2. Gets the public HTTPS URL via ngrok's API - * 3. Updates the appview .env file with the ngrok URL - * 4. Starts both the API server and client app - */ - -const { execSync, spawn } = require('child_process') -const fs = require('fs') -const path = require('path') -const http = require('http') -const { URL } = require('url') - -const appviewEnvPath = path.join(__dirname, '..', 'packages', 'appview', '.env') -const clientEnvPath = path.join(__dirname, '..', 'packages', 'client', '.env') - -// Check if ngrok is installed -try { - execSync('ngrok --version', { stdio: 'ignore' }) -} catch (error) { - console.error('❌ ngrok is not installed or not in your PATH.') - console.error('Please install ngrok from https://ngrok.com/download') - process.exit(1) -} - -// Kill any existing ngrok processes -try { - if (process.platform === 'win32') { - execSync('taskkill /f /im ngrok.exe', { stdio: 'ignore' }) - } else { - execSync('pkill -f ngrok', { stdio: 'ignore' }) - } - // Wait for processes to terminate - try { - execSync('sleep 1') - } catch (e) {} -} catch (error) { - // If no process was found, it will throw an error, which we can ignore -} - -console.log('🚀 Starting ngrok...') - -// Start ngrok process - now we're exposing the client (3000) instead of the API (3001) -// This way the whole app will be served through ngrok -const ngrokProcess = spawn('ngrok', ['http', '3000'], { - stdio: ['ignore', 'pipe', 'pipe'], // Allow stdout, stderr -}) - -let devProcesses = null - -// Helper function to update .env files -function updateEnvFile(filePath, ngrokUrl) { - if (!fs.existsSync(filePath)) { - fs.writeFileSync(filePath, '') - } - - const content = fs.readFileSync(filePath, 'utf8') - - if (filePath.includes('appview')) { - // Update NGROK_URL in the appview package - const varName = 'NGROK_URL' - const publicUrlName = 'PUBLIC_URL' - const regex = new RegExp(`^${varName}=.*$`, 'm') - const publicUrlRegex = new RegExp(`^${publicUrlName}=.*$`, 'm') - - // Update content - let updatedContent = content - - // Update or add NGROK_URL - if (regex.test(updatedContent)) { - updatedContent = updatedContent.replace(regex, `${varName}=${ngrokUrl}`) - } else { - updatedContent = `${updatedContent}\n${varName}=${ngrokUrl}\n` - } - - // Update or add PUBLIC_URL - set it to the ngrok URL too - if (publicUrlRegex.test(updatedContent)) { - updatedContent = updatedContent.replace( - publicUrlRegex, - `${publicUrlName}=${ngrokUrl}`, - ) - } else { - updatedContent = `${updatedContent}\n${publicUrlName}=${ngrokUrl}\n` - } - - fs.writeFileSync(filePath, updatedContent) - console.log( - `✅ Updated ${path.basename(filePath)} with ${varName}=${ngrokUrl} and ${publicUrlName}=${ngrokUrl}`, - ) - } else if (filePath.includes('client')) { - // For client, set VITE_API_URL to "/api" - this ensures it uses the proxy setup - const varName = 'VITE_API_URL' - const regex = new RegExp(`^${varName}=.*$`, 'm') - - let updatedContent - if (regex.test(content)) { - // Update existing variable - updatedContent = content.replace(regex, `${varName}=/api`) - } else { - // Add new variable - updatedContent = `${content}\n${varName}=/api\n` - } - - fs.writeFileSync(filePath, updatedContent) - console.log( - `✅ Updated ${path.basename(filePath)} with ${varName}=/api (proxy to API server)`, - ) - } -} - -// Function to start the development servers -function startDevServers() { - console.log('🚀 Starting development servers...') - - // Free port 3001 if it's in use - try { - if (process.platform !== 'win32') { - // Kill any process using port 3001 - execSync('kill $(lsof -t -i:3001 2>/dev/null) 2>/dev/null || true') - // Wait for port to be released - execSync('sleep 1') - } - } catch (error) { - // Ignore errors - } - - // Start both servers - devProcesses = spawn('pnpm', ['--filter', '@statusphere/appview', 'dev'], { - stdio: 'inherit', - detached: false, - }) - - const clientProcess = spawn( - 'pnpm', - ['--filter', '@statusphere/client', 'dev'], - { - stdio: 'inherit', - detached: false, - }, - ) - - devProcesses.on('close', (code) => { - console.log(`API server exited with code ${code}`) - killAllProcesses() - }) - - clientProcess.on('close', (code) => { - console.log(`Client app exited with code ${code}`) - killAllProcesses() - }) -} - -// Function to get the ngrok URL from its API -function getNgrokUrl() { - return new Promise((resolve, reject) => { - // Wait a bit for ngrok to start its API server - setTimeout(() => { - http - .get('http://localhost:4040/api/tunnels', (res) => { - let data = '' - - res.on('data', (chunk) => { - data += chunk - }) - - res.on('end', () => { - try { - const tunnels = JSON.parse(data).tunnels - if (tunnels && tunnels.length > 0) { - // Find HTTPS tunnel - const httpsTunnel = tunnels.find((t) => t.proto === 'https') - if (httpsTunnel) { - resolve(httpsTunnel.public_url) - } else { - reject(new Error('No HTTPS tunnel found')) - } - } else { - reject(new Error('No tunnels found')) - } - } catch (error) { - reject(error) - } - }) - }) - .on('error', (err) => { - reject(err) - }) - }, 2000) // Give ngrok a couple seconds to start - }) -} - -// Poll the ngrok API until we get a URL -function pollNgrokApi() { - getNgrokUrl() - .then((ngrokUrl) => { - console.log(`🌍 ngrok URL: ${ngrokUrl}`) - - // Update .env files with the ngrok URL - updateEnvFile(appviewEnvPath, ngrokUrl) - // We'll still call this but it will be skipped per our updated logic - updateEnvFile(clientEnvPath, ngrokUrl) - - // Start development servers - startDevServers() - }) - .catch(() => { - // Try again in 1 second - setTimeout(pollNgrokApi, 1000) - }) -} - -// Start polling after a short delay -setTimeout(pollNgrokApi, 1000) - -// Handle errors -ngrokProcess.stderr.on('data', (data) => { - console.error('------- NGROK ERROR -------') - console.error(data.toString()) - console.error('---------------------------') -}) - -// Handle ngrok process exit -ngrokProcess.on('close', (code) => { - console.log(`ngrok process exited with code ${code}`) - // Call our kill function to ensure everything is properly cleaned up - killAllProcesses() -}) - -// Function to properly terminate all child processes -function killAllProcesses() { - console.log('\nShutting down development environment...') - - // Get ngrok process PID for force kill if needed - const ngrokPid = ngrokProcess.pid - - // Kill main processes with a normal signal first - if (devProcesses) { - try { - devProcesses.kill() - } catch (e) {} - } - - try { - ngrokProcess.kill() - } catch (e) {} - - // Force kill ngrok if normal kill fails - try { - if (process.platform === 'win32') { - execSync(`taskkill /F /PID ${ngrokPid} 2>nul`, { stdio: 'ignore' }) - } else { - execSync(`kill -9 ${ngrokPid} 2>/dev/null || true`, { stdio: 'ignore' }) - // Also kill any remaining ngrok processes - execSync('pkill -9 -f ngrok 2>/dev/null || true', { stdio: 'ignore' }) - } - } catch (e) { - // Ignore errors if processes are already gone - } - - // Kill any process on port 3001 to ensure clean exit - try { - if (process.platform !== 'win32') { - execSync('kill $(lsof -t -i:3001 2>/dev/null) 2>/dev/null || true', { - stdio: 'ignore', - }) - } - } catch (e) { - // Ignore errors - } - - process.exit(0) -} - -// Handle various termination signals -process.on('SIGINT', killAllProcesses) // Ctrl+C -process.on('SIGTERM', killAllProcesses) // Kill command -process.on('SIGHUP', killAllProcesses) // Terminal closed