diff --git a/api/index.mjs b/api/index.mjs new file mode 100644 index 0000000..916fb1f --- /dev/null +++ b/api/index.mjs @@ -0,0 +1,6 @@ +// Vercel serverless function entry point (ES Module) +// Import the compiled Express app +const { default: app } = await import('../backend/dist/server.js'); + +// Export the Express app for Vercel +export default app; \ No newline at end of file diff --git a/backend/atxp-utils.test.ts b/backend/atxp-utils.test.ts index 5cd2b4e..5948da2 100644 --- a/backend/atxp-utils.test.ts +++ b/backend/atxp-utils.test.ts @@ -6,13 +6,15 @@ vi.mock('@atxp/client', () => ({ ATXPAccount: vi.fn().mockImplementation(() => ({ accountId: 'test-account' })) })); -import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils'; +import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils.js'; import { ATXPAccount } from '@atxp/client'; describe('ATXP Utils', () => { beforeEach(() => { // Clear environment variables before each test delete process.env.ATXP_CONNECTION_STRING; + // Reset mocks + vi.clearAllMocks(); }); describe('getATXPConnectionString', () => { @@ -121,6 +123,7 @@ describe('ATXP Utils', () => { const result = findATXPAccount(connectionString); + expect(ATXPAccount).toHaveBeenCalledWith(connectionString, { network: 'base' }); expect(result).toEqual({ accountId: 'test-account' }); }); }); @@ -182,8 +185,8 @@ describe('ATXP Utils', () => { } } as Partial as Request; - // Mock ATXPAccount to throw an error for this test - (ATXPAccount as any).mockImplementationOnce(() => { + // Mock ATXPAccount to throw an error + vi.mocked(ATXPAccount).mockImplementationOnce(() => { throw new Error('Invalid connection string format'); }); diff --git a/backend/package-lock.json b/backend/package-lock.json index 2bfbdd5..c2d810e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -26,6 +26,9 @@ "supertest": "^7.1.4", "ts-node": "^10.9.0", "vitest": "^3.2.4" + }, + "engines": { + "node": "22.x" } }, "node_modules/@0no-co/graphql.web": { diff --git a/backend/package.json b/backend/package.json index 6a62426..a32c0f0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,10 +2,14 @@ "name": "agent-demo-backend", "version": "1.0.0", "description": "Express backend for agent-demo", + "type": "module", "main": "dist/server.js", + "engines": { + "node": "22.x" + }, "scripts": { "start": "NODE_ENV=production node dist/server.js", - "dev": "nodemon --exec ts-node server.ts", + "dev": "nodemon --exec node --loader ts-node/esm server.ts", "build": "tsc", "build:worker": "echo 'Cloudflare Workers will build directly from TypeScript source'", "test": "vitest run", diff --git a/backend/server.test.ts b/backend/server.test.ts index 9742dd7..f6e2b20 100644 --- a/backend/server.test.ts +++ b/backend/server.test.ts @@ -16,7 +16,7 @@ vi.mock('@atxp/common', () => ({ })); // Mock the stage module -vi.mock('./stage', () => ({ +vi.mock('./stage.js', () => ({ sendSSEUpdate: vi.fn(), addSSEClient: vi.fn(), removeSSEClient: vi.fn(), @@ -24,7 +24,7 @@ vi.mock('./stage', () => ({ })); // Import after mocking -import { getATXPConnectionString, validateATXPConnectionString } from './atxp-utils'; +import { getATXPConnectionString, validateATXPConnectionString } from './atxp-utils.js'; describe('API Endpoints', () => { let app: express.Application; diff --git a/backend/server.ts b/backend/server.ts index 8c74053..7135ad2 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -4,14 +4,19 @@ import bodyParser from 'body-parser'; import path from 'path'; import fs from 'fs'; import dotenv from 'dotenv'; -import { sendSSEUpdate, addSSEClient, removeSSEClient, sendStageUpdate, sendPaymentUpdate } from './stage'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; -// Import the ATXP client SDK -import { atxpClient, ATXPAccount } from '@atxp/client'; -import { ConsoleLogger, LogLevel } from '@atxp/common'; +// ESM __dirname polyfill +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +import { sendSSEUpdate, addSSEClient, removeSSEClient, sendStageUpdate, sendPaymentUpdate } from './stage.js'; + +// ATXP client SDK imports (will be dynamically imported due to ES module compatibility) // Import ATXP utility functions -import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils'; +import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils.js'; +import type { ATXPAccount } from '@atxp/client'; // Load environment variables // In production, __dirname points to dist/, but .env is in the parent directory @@ -26,12 +31,34 @@ const PORT = process.env.PORT || 3001; const FRONTEND_PORT = process.env.FRONTEND_PORT || 3000; // Set up CORS and body parsing middleware -app.use(cors({ - origin: [`http://localhost:${FRONTEND_PORT}`, `http://localhost:${PORT}`], +const corsOptions = { + origin: (origin: string | undefined, callback: (error: Error | null, allow?: boolean) => void) => { + // Allow requests with no origin (mobile apps, curl, etc.) + if (!origin) return callback(null, true); + + // In development, allow localhost + if (process.env.NODE_ENV !== 'production') { + const allowedOrigins = [`http://localhost:${FRONTEND_PORT}`, `http://localhost:${PORT}`]; + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + } + + // In production, allow any origin since we're serving both API and frontend from same domain + // This is safe because in production, the frontend is served by the same Express server + if (process.env.NODE_ENV === 'production') { + return callback(null, true); + } + + // For development, reject unknown origins + callback(new Error('Not allowed by CORS'), false); + }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'Cache-Control', 'x-atxp-connection-string'] -})); +}; + +app.use(cors(corsOptions)); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); @@ -84,8 +111,9 @@ const filestoreService = { // Handle OPTIONS for SSE endpoint app.options('/api/progress', (req: Request, res: Response) => { + const origin = req.headers.origin || req.headers.host || `http://localhost:${FRONTEND_PORT}`; res.writeHead(200, { - 'Access-Control-Allow-Origin': `http://localhost:${FRONTEND_PORT}`, + 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, x-atxp-connection-string', 'Access-Control-Allow-Methods': 'GET, OPTIONS' @@ -95,11 +123,12 @@ app.options('/api/progress', (req: Request, res: Response) => { // SSE endpoint for progress updates app.get('/api/progress', (req: Request, res: Response) => { + const origin = req.headers.origin || req.headers.host || `http://localhost:${FRONTEND_PORT}`; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': `http://localhost:${FRONTEND_PORT}`, + 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, x-atxp-connection-string', 'Access-Control-Allow-Methods': 'GET, OPTIONS' @@ -167,11 +196,12 @@ async function pollForTaskCompletion( // Send stage update for file storage sendStageUpdate(requestId, 'storing-file', 'Storing image in ATXP Filestore...', 'in-progress'); - // Create filestore client - const filestoreClient = await atxpClient({ + // Create filestore client with dynamic import + const { atxpClient: filestoreAtxpClient } = await import('@atxp/client'); + const filestoreClient = await filestoreAtxpClient({ mcpServer: filestoreService.mcpServer, account: account, - onPayment: async ({ payment }) => { + onPayment: async ({ payment }: { payment: any }) => { console.log('Payment made to filestore:', payment); sendPaymentUpdate({ accountId: payment.accountId, @@ -302,13 +332,17 @@ app.post('/api/texts', async (req: Request, res: Response) => { // Send stage update for client creation sendStageUpdate(requestId, 'creating-clients', 'Initializing ATXP clients...', 'in-progress'); + // Dynamically import ATXP modules + const { atxpClient } = await import('@atxp/client'); + const { ConsoleLogger, LogLevel } = await import('@atxp/common'); + // Create a client using the `atxpClient` function for the ATXP Image MCP Server const imageClient = await atxpClient({ mcpServer: imageService.mcpServer, account: account, allowedAuthorizationServers: [`http://localhost:${PORT}`, 'https://auth.atxp.ai', 'https://atxp-accounts-staging.onrender.com/'], logger: new ConsoleLogger({level: LogLevel.DEBUG}), - onPayment: async ({ payment }) => { + onPayment: async ({ payment }: { payment: any }) => { console.log('Payment made to image service:', payment); sendPaymentUpdate({ accountId: payment.accountId, @@ -393,23 +427,47 @@ app.get('/api/validate-connection', (req: Request, res: Response) => { // Helper to resolve static path for frontend build function getStaticPath() { - // Try ./frontend/build first (works when running from project root in development) - let candidate = path.join(__dirname, './frontend/build'); - if (fs.existsSync(candidate)) { - return candidate; - } - // Try ../frontend/build (works when running from backend/ directory) - candidate = path.join(__dirname, '../frontend/build'); - if (fs.existsSync(candidate)) { - return candidate; + const candidates = [ + // Development: running from project root + path.join(__dirname, './frontend/build'), + // Development: running from backend/ directory + path.join(__dirname, '../frontend/build'), + // Production: running from backend/dist/ + path.join(__dirname, '../../frontend/build'), + // Vercel: frontend build copied to backend/public directory + path.join(__dirname, './public'), + path.join(__dirname, '../public'), + // Vercel: alternative paths + '/var/task/backend/public', + // Development fallback + path.join(__dirname, '../build') + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } } - // Try ../../frontend/build (works when running from backend/dist/ in production) - candidate = path.join(__dirname, '../../frontend/build'); - if (fs.existsSync(candidate)) { - return candidate; + + // List contents of current directory for debugging + try { + const currentDirContents = fs.readdirSync(__dirname); + + // Also check if build directory exists but is empty + const buildPath = path.join(__dirname, './build'); + if (fs.existsSync(buildPath)) { + try { + const buildContents = fs.readdirSync(buildPath); + } catch (error) { + console.log('Could not read build directory contents:', error); + } + } + } catch (error) { + console.log('Could not read __dirname contents:', error); } - // Fallback: throw error - throw new Error('No frontend build directory found. Make sure to run "npm run build" first.'); + + // Fallback: throw error with more debugging info + throw new Error(`No frontend build directory found. __dirname: ${__dirname}. Checked paths: ${candidates.join(', ')}`); } // Serve static files in production @@ -428,6 +486,12 @@ if (process.env.NODE_ENV === 'production') { }); } -app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); -}); +// For Vercel serverless deployment, export the app +export default app; + +// For local development, start the server +if (process.env.NODE_ENV !== 'production' || process.env.VERCEL !== '1') { + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 73a74be..c31974e 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,18 +1,20 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", + "module": "ESNext", "lib": ["ES2020", "dom"], "outDir": "./dist", "rootDir": "./", "strict": true, "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "moduleResolution": "node" }, "include": [ "server.ts", diff --git a/backend/vitest.config.js b/backend/vitest.config.js new file mode 100644 index 0000000..ec52ffb --- /dev/null +++ b/backend/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node' + }, +}); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ce53918..aed3a09 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -161,9 +161,23 @@ function App(): JSX.Element { } console.log('Setting up SSE connection...'); - // Use direct backend URL since SSE doesn't work well through CRA proxy - const backendPort = process.env.REACT_APP_BACKEND_PORT || '3001'; - const eventSource = new EventSource(`http://localhost:${backendPort}/api/progress`); + + // Use NODE_ENV to determine if we're in development mode with separate servers + // In development, we typically run frontend and backend on separate ports + // In production, they're served from the same domain + const isDevelopment = process.env.NODE_ENV === 'development'; + + let sseUrl: string; + if (isDevelopment) { + // Development: use direct backend URL since CRA proxy doesn't handle SSE well + const backendPort = process.env.REACT_APP_BACKEND_PORT || '3001'; + sseUrl = `http://localhost:${backendPort}/api/progress`; + } else { + // Production/deployed: use relative URL (same origin) + sseUrl = '/api/progress'; + } + + const eventSource = new EventSource(sseUrl); eventSourceRef.current = eventSource; console.log('EventSource created, readyState:', eventSource.readyState); diff --git a/vercel.json b/vercel.json index ddb88da..52c7c15 100644 --- a/vercel.json +++ b/vercel.json @@ -1,27 +1,12 @@ { "version": 2, - "builds": [ + "installCommand": "npm run install-all", + "buildCommand": "npm run build && cp -r frontend/build backend/public", + "outputDirectory": "backend", + "rewrites": [ { - "src": "backend/server.ts", - "use": "@vercel/node", - "config": { - "includeFiles": ["backend/**", "frontend/build/**"] - } + "source": "/(.*)", + "destination": "/api" } - ], - "routes": [ - { - "src": "/(.*)", - "dest": "/backend/server.ts" - } - ], - "installCommand": "npm run setup", - "env": { - "NODE_ENV": "production" - }, - "functions": { - "backend/server.ts": { - "maxDuration": 30 - } - } + ] } \ No newline at end of file