Skip to content

Commit 54e4c66

Browse files
committed
server(vercel): export serverless handler; avoid listen() on Vercel\n\n- Add default export handler that forwards Node req/res to Fastify\n- Initialize app once; avoid process.exit in serverless\n- Keep listen() for local dev only\n\nci(vercel): robust deploy URL parsing + health retries\n\n- Use "--output=json" and parse URL reliably\n- Replace fixed sleep with retry loop for server and web\n- Download web build artifact before deploy\n\nchore: remove duplicate root vercel.json to prevent config conflicts
1 parent b49a812 commit 54e4c66

3 files changed

Lines changed: 124 additions & 76 deletions

File tree

.github/workflows/test-deploy.yml

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,28 @@ jobs:
104104
working-directory: server
105105
id: deploy
106106
run: |
107-
DEPLOY_OUTPUT=$(npx vercel deploy --prod --yes --token="$VERCEL_TOKEN")
107+
set -e
108+
# Use JSON output to reliably capture the deployment URL
109+
DEPLOY_OUTPUT=$(npx vercel deploy --prod --yes --token="$VERCEL_TOKEN" --output=json)
108110
echo "$DEPLOY_OUTPUT"
109-
DEPLOY_URL=$(echo "$DEPLOY_OUTPUT" | grep "Production:" | grep -o 'https://[^[:space:]]*')
111+
DEPLOY_URL=$(echo "$DEPLOY_OUTPUT" | node -e "const fs=require('fs');const lines=fs.readFileSync(0,'utf8').trim().split(/\n/).filter(Boolean);const objs=lines.map(l=>{try{return JSON.parse(l)}catch(e){return null}}).filter(Boolean);const last=objs.reverse().find(o=>o.url);if(!last){process.exit(2)}console.log(last.url)")
110112
echo "deploy_url=$DEPLOY_URL" >> $GITHUB_OUTPUT
111113
echo "Deployed to: $DEPLOY_URL"
112114
- name: Health check server
113115
run: |
114116
echo "Waiting for deployment to be ready..."
115-
sleep 30
116117
DEPLOY_URL="${{ steps.deploy.outputs.deploy_url }}"
117118
echo "Testing health at: $DEPLOY_URL/api/health"
118-
curl -f "$DEPLOY_URL/api/health" || exit 1
119-
echo "✅ Server health check passed"
119+
for i in {1..10}; do
120+
if curl -fsS "$DEPLOY_URL/api/health" > /dev/null; then
121+
echo "✅ Server health check passed"
122+
exit 0
123+
fi
124+
echo "Attempt $i failed; retrying in 6s..."
125+
sleep 6
126+
done
127+
echo "❌ Server health check failed"
128+
exit 1
120129
121130
# -------------------------
122131
# Web: deploy Flutter web (requires BOTH tests, only on main)
@@ -139,11 +148,16 @@ jobs:
139148
with:
140149
node-version: 20
141150
# Deploy using pre-built files from git
151+
- name: Download web build artifact
152+
uses: actions/download-artifact@v4
153+
with:
154+
name: web-build
155+
path: mobile/build/web
142156
- name: Verify web build files exist
143157
working-directory: mobile
144158
run: |
145159
if [ ! -d "build/web" ]; then
146-
echo "❌ Error: build/web directory not found. Make sure to commit Flutter web build files."
160+
echo "❌ Error: build/web directory not found after artifact download."
147161
exit 1
148162
fi
149163
echo "✅ Web build files found"
@@ -155,19 +169,27 @@ jobs:
155169
working-directory: mobile
156170
id: deploy-mobile
157171
run: |
158-
DEPLOY_OUTPUT=$(npx vercel deploy --prod --yes --token="$VERCEL_TOKEN")
172+
set -e
173+
DEPLOY_OUTPUT=$(npx vercel deploy --prod --yes --token="$VERCEL_TOKEN" --output=json)
159174
echo "$DEPLOY_OUTPUT"
160-
DEPLOY_URL=$(echo "$DEPLOY_OUTPUT" | grep "Production:" | grep -o 'https://[^[:space:]]*')
175+
DEPLOY_URL=$(echo "$DEPLOY_OUTPUT" | node -e "const fs=require('fs');const lines=fs.readFileSync(0,'utf8').trim().split(/\n/).filter(Boolean);const objs=lines.map(l=>{try{return JSON.parse(l)}catch(e){return null}}).filter(Boolean);const last=objs.reverse().find(o=>o.url);if(!last){process.exit(2)}console.log(last.url)")
161176
echo "deploy_url=$DEPLOY_URL" >> $GITHUB_OUTPUT
162177
echo "Deployed to: $DEPLOY_URL"
163178
- name: Health check mobile app
164179
run: |
165180
echo "Waiting for deployment to be ready..."
166-
sleep 30
167181
DEPLOY_URL="${{ steps.deploy-mobile.outputs.deploy_url }}"
168182
echo "Testing mobile app at: $DEPLOY_URL"
169-
curl -f "$DEPLOY_URL" || exit 1
170-
echo "✅ Mobile app health check passed"
183+
for i in {1..10}; do
184+
if curl -fsS "$DEPLOY_URL" > /dev/null; then
185+
echo "✅ Mobile app health check passed"
186+
exit 0
187+
fi
188+
echo "Attempt $i failed; retrying in 6s..."
189+
sleep 6
190+
done
191+
echo "❌ Mobile app health check failed"
192+
exit 1
171193
172194
# -------------------------
173195
# Integration tests after deployment

server/src/index.ts

Lines changed: 91 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { getRateLimitConfig } from './config/rateLimit';
99
import { createAdminAuthMiddleware } from './middleware/adminAuth';
1010
import path from 'path';
1111

12+
// Create Fastify instance early; we will either
13+
// - export a Vercel handler that forwards requests to it, or
14+
// - call listen() for local/dev environments.
1215
const fastify = Fastify({
1316
logger: process.env.NODE_ENV === 'development' ? {
1417
level: process.env.LOG_LEVEL || 'info',
@@ -56,62 +59,78 @@ const fastify = Fastify({
5659
},
5760
},
5861
});
62+
let initialized = false;
63+
let adminAuthOnce: ReturnType<typeof createAdminAuthMiddleware> | null = null;
5964

60-
async function start() {
61-
try {
62-
// Initialize admin authentication
63-
const adminSecret = process.env.ADMIN_SECRET;
64-
if (!adminSecret || adminSecret.length !== 64) {
65-
console.error('ERROR: ADMIN_SECRET must be a 64-character string');
65+
async function initApp() {
66+
if (initialized) return;
67+
// Initialize admin authentication
68+
const adminSecret = process.env.ADMIN_SECRET;
69+
if (!adminSecret || adminSecret.length !== 64) {
70+
const msg = 'ERROR: ADMIN_SECRET must be a 64-character string';
71+
console.error(msg);
72+
// In serverless environments, avoid exiting the process; throw instead
73+
if (process.env.VERCEL) {
74+
throw new Error(msg);
75+
} else {
6676
console.error('Please set ADMIN_SECRET in your environment variables');
6777
process.exit(1);
6878
}
69-
const adminAuth = createAdminAuthMiddleware({ adminSecret });
70-
71-
// Initialize rate limiting with configuration
72-
const rateLimitConfig = getRateLimitConfig();
73-
rateLimiterService.updateConfig(rateLimitConfig);
74-
75-
// Register CORS
76-
await fastify.register(cors, {
77-
origin: true,
78-
credentials: true,
79-
});
80-
81-
// Register rate limiters
82-
await rateLimiterService.registerRateLimiters(fastify);
83-
84-
// Register static files for admin UI
85-
await fastify.register(staticFiles, {
86-
root: path.join(__dirname, 'public'),
87-
prefix: '/admin/',
88-
});
89-
90-
// Add authentication middleware to admin routes
91-
fastify.addHook('preHandler', async (request, reply) => {
92-
// Only apply admin auth to /admin/* routes
93-
if (request.url.startsWith('/admin/')) {
94-
await adminAuth(request, reply);
95-
}
96-
});
97-
98-
// Register API routes
99-
await fastify.register(cleanRoutes, { prefix: '/api' });
100-
await fastify.register(strategyRoutes, { prefix: '/api' });
101-
102-
// Health check endpoint
103-
fastify.get('/health', async (_request, _reply) => {
104-
return { status: 'ok', timestamp: new Date().toISOString() };
105-
});
106-
107-
// Root redirect to admin UI (protected)
108-
fastify.get('/', {
109-
preHandler: adminAuth,
110-
}, async (_request, reply) => {
111-
return reply.redirect('/admin/');
112-
});
113-
114-
// Start server
79+
}
80+
adminAuthOnce = createAdminAuthMiddleware({ adminSecret });
81+
82+
// Initialize rate limiting with configuration
83+
const rateLimitConfig = getRateLimitConfig();
84+
rateLimiterService.updateConfig(rateLimitConfig);
85+
86+
// Register CORS
87+
await fastify.register(cors, {
88+
origin: true,
89+
credentials: true,
90+
});
91+
92+
// Register rate limiters
93+
await rateLimiterService.registerRateLimiters(fastify);
94+
95+
// Register static files for admin UI
96+
await fastify.register(staticFiles, {
97+
root: path.join(__dirname, 'public'),
98+
prefix: '/admin/',
99+
});
100+
101+
// Add authentication middleware to admin routes
102+
fastify.addHook('preHandler', async (request, reply) => {
103+
// Only apply admin auth to /admin/* routes
104+
if (request.url.startsWith('/admin/') && adminAuthOnce) {
105+
await adminAuthOnce(request, reply);
106+
}
107+
});
108+
109+
// Register API routes
110+
await fastify.register(cleanRoutes, { prefix: '/api' });
111+
await fastify.register(strategyRoutes, { prefix: '/api' });
112+
113+
// Health check endpoint (non-prefixed)
114+
fastify.get('/health', async (_request, _reply) => {
115+
return { status: 'ok', timestamp: new Date().toISOString() };
116+
});
117+
118+
// Root redirect to admin UI (protected)
119+
fastify.get('/', {
120+
preHandler: adminAuthOnce!,
121+
}, async (_request, reply) => {
122+
return reply.redirect('/admin/');
123+
});
124+
125+
await fastify.ready();
126+
initialized = true;
127+
}
128+
129+
async function start() {
130+
try {
131+
await initApp();
132+
133+
// Start server for local/dev environments
115134
const port = parseInt(process.env.PORT || '3000', 10);
116135
const host = process.env.HOST || 'localhost';
117136

@@ -139,4 +158,23 @@ process.on('SIGTERM', async () => {
139158
process.exit(0);
140159
});
141160

142-
start();
161+
// If running on Vercel (@vercel/node), export a handler instead of listening
162+
// This allows the serverless function to dispatch requests to Fastify without binding a port
163+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
164+
export default async function handler(req: any, res: any) {
165+
try {
166+
await initApp();
167+
// Forward the incoming request to Fastify's underlying Node server
168+
fastify.server.emit('request', req, res);
169+
} catch (err) {
170+
// If initialization fails (e.g., missing ADMIN_SECRET), return 500
171+
res.statusCode = 500;
172+
res.setHeader('Content-Type', 'application/json');
173+
res.end(JSON.stringify({ success: false, error: 'Initialization error' }));
174+
}
175+
}
176+
177+
// Start a real server only when not in Vercel/serverless environment
178+
if (!process.env.VERCEL) {
179+
start();
180+
}

vercel.json

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)