Skip to content

Commit ee91edc

Browse files
Complete ESM conversion and improve typing throughout the codebase (#18)
This PR completes the ESM (ES Module) conversion of the backend and improves typing throughout the codebase, along with several deployment and development experience improvements. It also adds in some frontend features to support deploying to Render and Vercel
1 parent 5a15d46 commit ee91edc

10 files changed

Lines changed: 154 additions & 65 deletions

File tree

api/index.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Vercel serverless function entry point (ES Module)
2+
// Import the compiled Express app
3+
const { default: app } = await import('../backend/dist/server.js');
4+
5+
// Export the Express app for Vercel
6+
export default app;

backend/atxp-utils.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ vi.mock('@atxp/client', () => ({
66
ATXPAccount: vi.fn().mockImplementation(() => ({ accountId: 'test-account' }))
77
}));
88

9-
import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils';
9+
import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils.js';
1010
import { ATXPAccount } from '@atxp/client';
1111

1212
describe('ATXP Utils', () => {
1313
beforeEach(() => {
1414
// Clear environment variables before each test
1515
delete process.env.ATXP_CONNECTION_STRING;
16+
// Reset mocks
17+
vi.clearAllMocks();
1618
});
1719

1820
describe('getATXPConnectionString', () => {
@@ -121,6 +123,7 @@ describe('ATXP Utils', () => {
121123

122124
const result = findATXPAccount(connectionString);
123125

126+
expect(ATXPAccount).toHaveBeenCalledWith(connectionString, { network: 'base' });
124127
expect(result).toEqual({ accountId: 'test-account' });
125128
});
126129
});
@@ -182,8 +185,8 @@ describe('ATXP Utils', () => {
182185
}
183186
} as Partial<Request> as Request;
184187

185-
// Mock ATXPAccount to throw an error for this test
186-
(ATXPAccount as any).mockImplementationOnce(() => {
188+
// Mock ATXPAccount to throw an error
189+
vi.mocked(ATXPAccount).mockImplementationOnce(() => {
187190
throw new Error('Invalid connection string format');
188191
});
189192

backend/package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
"name": "agent-demo-backend",
33
"version": "1.0.0",
44
"description": "Express backend for agent-demo",
5+
"type": "module",
56
"main": "dist/server.js",
7+
"engines": {
8+
"node": "22.x"
9+
},
610
"scripts": {
711
"start": "NODE_ENV=production node dist/server.js",
8-
"dev": "nodemon --exec ts-node server.ts",
12+
"dev": "nodemon --exec node --loader ts-node/esm server.ts",
913
"build": "tsc",
1014
"build:worker": "echo 'Cloudflare Workers will build directly from TypeScript source'",
1115
"test": "vitest run",

backend/server.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ vi.mock('@atxp/common', () => ({
1616
}));
1717

1818
// Mock the stage module
19-
vi.mock('./stage', () => ({
19+
vi.mock('./stage.js', () => ({
2020
sendSSEUpdate: vi.fn(),
2121
addSSEClient: vi.fn(),
2222
removeSSEClient: vi.fn(),
2323
sendStageUpdate: vi.fn(),
2424
}));
2525

2626
// Import after mocking
27-
import { getATXPConnectionString, validateATXPConnectionString } from './atxp-utils';
27+
import { getATXPConnectionString, validateATXPConnectionString } from './atxp-utils.js';
2828

2929
describe('API Endpoints', () => {
3030
let app: express.Application;

backend/server.ts

Lines changed: 96 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@ import bodyParser from 'body-parser';
44
import path from 'path';
55
import fs from 'fs';
66
import dotenv from 'dotenv';
7-
import { sendSSEUpdate, addSSEClient, removeSSEClient, sendStageUpdate, sendPaymentUpdate } from './stage';
7+
import { fileURLToPath } from 'url';
8+
import { dirname } from 'path';
89

9-
// Import the ATXP client SDK
10-
import { atxpClient, ATXPAccount } from '@atxp/client';
11-
import { ConsoleLogger, LogLevel } from '@atxp/common';
10+
// ESM __dirname polyfill
11+
const __filename = fileURLToPath(import.meta.url);
12+
const __dirname = dirname(__filename);
13+
import { sendSSEUpdate, addSSEClient, removeSSEClient, sendStageUpdate, sendPaymentUpdate } from './stage.js';
14+
15+
// ATXP client SDK imports (will be dynamically imported due to ES module compatibility)
1216

1317
// Import ATXP utility functions
14-
import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils';
18+
import { getATXPConnectionString, findATXPAccount, validateATXPConnectionString } from './atxp-utils.js';
19+
import type { ATXPAccount } from '@atxp/client';
1520

1621
// Load environment variables
1722
// In production, __dirname points to dist/, but .env is in the parent directory
@@ -26,12 +31,34 @@ const PORT = process.env.PORT || 3001;
2631
const FRONTEND_PORT = process.env.FRONTEND_PORT || 3000;
2732

2833
// Set up CORS and body parsing middleware
29-
app.use(cors({
30-
origin: [`http://localhost:${FRONTEND_PORT}`, `http://localhost:${PORT}`],
34+
const corsOptions = {
35+
origin: (origin: string | undefined, callback: (error: Error | null, allow?: boolean) => void) => {
36+
// Allow requests with no origin (mobile apps, curl, etc.)
37+
if (!origin) return callback(null, true);
38+
39+
// In development, allow localhost
40+
if (process.env.NODE_ENV !== 'production') {
41+
const allowedOrigins = [`http://localhost:${FRONTEND_PORT}`, `http://localhost:${PORT}`];
42+
if (allowedOrigins.includes(origin)) {
43+
return callback(null, true);
44+
}
45+
}
46+
47+
// In production, allow any origin since we're serving both API and frontend from same domain
48+
// This is safe because in production, the frontend is served by the same Express server
49+
if (process.env.NODE_ENV === 'production') {
50+
return callback(null, true);
51+
}
52+
53+
// For development, reject unknown origins
54+
callback(new Error('Not allowed by CORS'), false);
55+
},
3156
credentials: true,
3257
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
3358
allowedHeaders: ['Content-Type', 'Authorization', 'Cache-Control', 'x-atxp-connection-string']
34-
}));
59+
};
60+
61+
app.use(cors(corsOptions));
3562
app.use(bodyParser.json());
3663
app.use(bodyParser.urlencoded({ extended: true }));
3764

@@ -84,8 +111,9 @@ const filestoreService = {
84111

85112
// Handle OPTIONS for SSE endpoint
86113
app.options('/api/progress', (req: Request, res: Response) => {
114+
const origin = req.headers.origin || req.headers.host || `http://localhost:${FRONTEND_PORT}`;
87115
res.writeHead(200, {
88-
'Access-Control-Allow-Origin': `http://localhost:${FRONTEND_PORT}`,
116+
'Access-Control-Allow-Origin': origin,
89117
'Access-Control-Allow-Credentials': 'true',
90118
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, x-atxp-connection-string',
91119
'Access-Control-Allow-Methods': 'GET, OPTIONS'
@@ -95,11 +123,12 @@ app.options('/api/progress', (req: Request, res: Response) => {
95123

96124
// SSE endpoint for progress updates
97125
app.get('/api/progress', (req: Request, res: Response) => {
126+
const origin = req.headers.origin || req.headers.host || `http://localhost:${FRONTEND_PORT}`;
98127
res.writeHead(200, {
99128
'Content-Type': 'text/event-stream',
100129
'Cache-Control': 'no-cache, no-store, must-revalidate',
101130
'Connection': 'keep-alive',
102-
'Access-Control-Allow-Origin': `http://localhost:${FRONTEND_PORT}`,
131+
'Access-Control-Allow-Origin': origin,
103132
'Access-Control-Allow-Credentials': 'true',
104133
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, x-atxp-connection-string',
105134
'Access-Control-Allow-Methods': 'GET, OPTIONS'
@@ -167,11 +196,12 @@ async function pollForTaskCompletion(
167196
// Send stage update for file storage
168197
sendStageUpdate(requestId, 'storing-file', 'Storing image in ATXP Filestore...', 'in-progress');
169198

170-
// Create filestore client
171-
const filestoreClient = await atxpClient({
199+
// Create filestore client with dynamic import
200+
const { atxpClient: filestoreAtxpClient } = await import('@atxp/client');
201+
const filestoreClient = await filestoreAtxpClient({
172202
mcpServer: filestoreService.mcpServer,
173203
account: account,
174-
onPayment: async ({ payment }) => {
204+
onPayment: async ({ payment }: { payment: any }) => {
175205
console.log('Payment made to filestore:', payment);
176206
sendPaymentUpdate({
177207
accountId: payment.accountId,
@@ -302,13 +332,17 @@ app.post('/api/texts', async (req: Request, res: Response) => {
302332
// Send stage update for client creation
303333
sendStageUpdate(requestId, 'creating-clients', 'Initializing ATXP clients...', 'in-progress');
304334

335+
// Dynamically import ATXP modules
336+
const { atxpClient } = await import('@atxp/client');
337+
const { ConsoleLogger, LogLevel } = await import('@atxp/common');
338+
305339
// Create a client using the `atxpClient` function for the ATXP Image MCP Server
306340
const imageClient = await atxpClient({
307341
mcpServer: imageService.mcpServer,
308342
account: account,
309343
allowedAuthorizationServers: [`http://localhost:${PORT}`, 'https://auth.atxp.ai', 'https://atxp-accounts-staging.onrender.com/'],
310344
logger: new ConsoleLogger({level: LogLevel.DEBUG}),
311-
onPayment: async ({ payment }) => {
345+
onPayment: async ({ payment }: { payment: any }) => {
312346
console.log('Payment made to image service:', payment);
313347
sendPaymentUpdate({
314348
accountId: payment.accountId,
@@ -393,23 +427,47 @@ app.get('/api/validate-connection', (req: Request, res: Response) => {
393427

394428
// Helper to resolve static path for frontend build
395429
function getStaticPath() {
396-
// Try ./frontend/build first (works when running from project root in development)
397-
let candidate = path.join(__dirname, './frontend/build');
398-
if (fs.existsSync(candidate)) {
399-
return candidate;
400-
}
401-
// Try ../frontend/build (works when running from backend/ directory)
402-
candidate = path.join(__dirname, '../frontend/build');
403-
if (fs.existsSync(candidate)) {
404-
return candidate;
430+
const candidates = [
431+
// Development: running from project root
432+
path.join(__dirname, './frontend/build'),
433+
// Development: running from backend/ directory
434+
path.join(__dirname, '../frontend/build'),
435+
// Production: running from backend/dist/
436+
path.join(__dirname, '../../frontend/build'),
437+
// Vercel: frontend build copied to backend/public directory
438+
path.join(__dirname, './public'),
439+
path.join(__dirname, '../public'),
440+
// Vercel: alternative paths
441+
'/var/task/backend/public',
442+
// Development fallback
443+
path.join(__dirname, '../build')
444+
];
445+
446+
for (const candidate of candidates) {
447+
if (fs.existsSync(candidate)) {
448+
return candidate;
449+
}
405450
}
406-
// Try ../../frontend/build (works when running from backend/dist/ in production)
407-
candidate = path.join(__dirname, '../../frontend/build');
408-
if (fs.existsSync(candidate)) {
409-
return candidate;
451+
452+
// List contents of current directory for debugging
453+
try {
454+
const currentDirContents = fs.readdirSync(__dirname);
455+
456+
// Also check if build directory exists but is empty
457+
const buildPath = path.join(__dirname, './build');
458+
if (fs.existsSync(buildPath)) {
459+
try {
460+
const buildContents = fs.readdirSync(buildPath);
461+
} catch (error) {
462+
console.log('Could not read build directory contents:', error);
463+
}
464+
}
465+
} catch (error) {
466+
console.log('Could not read __dirname contents:', error);
410467
}
411-
// Fallback: throw error
412-
throw new Error('No frontend build directory found. Make sure to run "npm run build" first.');
468+
469+
// Fallback: throw error with more debugging info
470+
throw new Error(`No frontend build directory found. __dirname: ${__dirname}. Checked paths: ${candidates.join(', ')}`);
413471
}
414472

415473
// Serve static files in production
@@ -428,6 +486,12 @@ if (process.env.NODE_ENV === 'production') {
428486
});
429487
}
430488

431-
app.listen(PORT, () => {
432-
console.log(`Server running on port ${PORT}`);
433-
});
489+
// For Vercel serverless deployment, export the app
490+
export default app;
491+
492+
// For local development, start the server
493+
if (process.env.NODE_ENV !== 'production' || process.env.VERCEL !== '1') {
494+
app.listen(PORT, () => {
495+
console.log(`Server running on port ${PORT}`);
496+
});
497+
}

backend/tsconfig.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
{
22
"compilerOptions": {
33
"target": "ES2020",
4-
"module": "commonjs",
4+
"module": "ESNext",
55
"lib": ["ES2020", "dom"],
66
"outDir": "./dist",
77
"rootDir": "./",
88
"strict": true,
99
"esModuleInterop": true,
10+
"allowSyntheticDefaultImports": true,
1011
"skipLibCheck": true,
1112
"forceConsistentCasingInFileNames": true,
1213
"resolveJsonModule": true,
1314
"declaration": true,
1415
"declarationMap": true,
15-
"sourceMap": true
16+
"sourceMap": true,
17+
"moduleResolution": "node"
1618
},
1719
"include": [
1820
"server.ts",

backend/vitest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
globals: true,
6+
environment: 'node'
7+
},
8+
});

frontend/src/App.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,23 @@ function App(): JSX.Element {
161161
}
162162

163163
console.log('Setting up SSE connection...');
164-
// Use direct backend URL since SSE doesn't work well through CRA proxy
165-
const backendPort = process.env.REACT_APP_BACKEND_PORT || '3001';
166-
const eventSource = new EventSource(`http://localhost:${backendPort}/api/progress`);
164+
165+
// Use NODE_ENV to determine if we're in development mode with separate servers
166+
// In development, we typically run frontend and backend on separate ports
167+
// In production, they're served from the same domain
168+
const isDevelopment = process.env.NODE_ENV === 'development';
169+
170+
let sseUrl: string;
171+
if (isDevelopment) {
172+
// Development: use direct backend URL since CRA proxy doesn't handle SSE well
173+
const backendPort = process.env.REACT_APP_BACKEND_PORT || '3001';
174+
sseUrl = `http://localhost:${backendPort}/api/progress`;
175+
} else {
176+
// Production/deployed: use relative URL (same origin)
177+
sseUrl = '/api/progress';
178+
}
179+
180+
const eventSource = new EventSource(sseUrl);
167181
eventSourceRef.current = eventSource;
168182

169183
console.log('EventSource created, readyState:', eventSource.readyState);

vercel.json

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,12 @@
11
{
22
"version": 2,
3-
"builds": [
3+
"installCommand": "npm run install-all",
4+
"buildCommand": "npm run build && cp -r frontend/build backend/public",
5+
"outputDirectory": "backend",
6+
"rewrites": [
47
{
5-
"src": "backend/server.ts",
6-
"use": "@vercel/node",
7-
"config": {
8-
"includeFiles": ["backend/**", "frontend/build/**"]
9-
}
8+
"source": "/(.*)",
9+
"destination": "/api"
1010
}
11-
],
12-
"routes": [
13-
{
14-
"src": "/(.*)",
15-
"dest": "/backend/server.ts"
16-
}
17-
],
18-
"installCommand": "npm run setup",
19-
"env": {
20-
"NODE_ENV": "production"
21-
},
22-
"functions": {
23-
"backend/server.ts": {
24-
"maxDuration": 30
25-
}
26-
}
11+
]
2712
}

0 commit comments

Comments
 (0)