diff --git a/package.json b/package.json index 6286c566..9064b349 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "README.md", "SECURITY.md", "LICENSE", - "docs" + "docs", + "build/public" ], "scripts": { "dev": "wrangler dev src/index.ts", diff --git a/rollup.config.js b/rollup.config.js index 86ea4e1c..b1eb22bf 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -14,7 +14,7 @@ export default { terser(), json(), copy({ - targets: [{ src: 'public/*', dest: 'build/public' }], + targets: [{ src: 'src/public/*', dest: 'build/public' }], }), ], }; diff --git a/src/middlewares/log/index.ts b/src/middlewares/log/index.ts index 79c9af0d..1e3a447e 100644 --- a/src/middlewares/log/index.ts +++ b/src/middlewares/log/index.ts @@ -8,16 +8,16 @@ const logClients: Map = new Map(); const addLogClient = (clientId: any, client: any) => { logClients.set(clientId, client); - console.log( - `New client ${clientId} connected. Total clients: ${logClients.size}` - ); + // console.log( + // `New client ${clientId} connected. Total clients: ${logClients.size}` + // ); }; const removeLogClient = (clientId: any) => { logClients.delete(clientId); - console.log( - `Client ${clientId} disconnected. Total clients: ${logClients.size}` - ); + // console.log( + // `Client ${clientId} disconnected. Total clients: ${logClients.size}` + // ); }; const broadcastLog = async (log: any) => { diff --git a/public/index.html b/src/public/index.html similarity index 100% rename from public/index.html rename to src/public/index.html diff --git a/public/main.js b/src/public/main.js similarity index 100% rename from public/main.js rename to src/public/main.js diff --git a/public/snippets.js b/src/public/snippets.js similarity index 100% rename from public/snippets.js rename to src/public/snippets.js diff --git a/public/styles/buttons.css b/src/public/styles/buttons.css similarity index 100% rename from public/styles/buttons.css rename to src/public/styles/buttons.css diff --git a/public/styles/header.css b/src/public/styles/header.css similarity index 100% rename from public/styles/header.css rename to src/public/styles/header.css diff --git a/public/styles/interative-code.css b/src/public/styles/interative-code.css similarity index 100% rename from public/styles/interative-code.css rename to src/public/styles/interative-code.css diff --git a/public/styles/logs.css b/src/public/styles/logs.css similarity index 100% rename from public/styles/logs.css rename to src/public/styles/logs.css diff --git a/public/styles/modal.css b/src/public/styles/modal.css similarity index 100% rename from public/styles/modal.css rename to src/public/styles/modal.css diff --git a/public/styles/style.css b/src/public/styles/style.css similarity index 100% rename from public/styles/style.css rename to src/public/styles/style.css diff --git a/public/styles/tabs.css b/src/public/styles/tabs.css similarity index 100% rename from public/styles/tabs.css rename to src/public/styles/tabs.css diff --git a/src/start-server.ts b/src/start-server.ts index 6b02cb9d..a3db483b 100644 --- a/src/start-server.ts +++ b/src/start-server.ts @@ -2,7 +2,6 @@ import { serve } from '@hono/node-server'; import { serveStatic } from '@hono/node-server/serve-static'; -import { exec } from 'child_process'; import app from './index'; import { streamSSE } from 'hono/streaming'; @@ -19,6 +18,7 @@ const port = portArg ? parseInt(portArg.split('=')[1]) : defaultPort; const isHeadless = args.includes('--headless'); +// Setup static file serving only if not in headless mode if ( !isHeadless && !( @@ -26,8 +26,56 @@ if ( process.env.ENVIRONMENT === 'production' ) ) { - app.get('/public/*', serveStatic({ root: './' })); - app.get('/public/logs', serveStatic({ path: './public/index.html' })); + const setupStaticServing = async () => { + const { join, dirname } = await import('path'); + const { fileURLToPath } = await import('url'); + const { readFileSync } = await import('fs'); + + const scriptDir = dirname(fileURLToPath(import.meta.url)); + + // Serve the index.html content directly for both routes + const indexPath = join(scriptDir, 'public/index.html'); + const indexContent = readFileSync(indexPath, 'utf-8'); + + const serveIndex = (c: Context) => { + return c.html(indexContent); + }; + + // Set up routes + app.get('/public/logs', serveIndex); + app.get('/public', serveIndex); + app.get('/public/', serveIndex); + + // Serve other static files + app.use( + '/public/*', + serveStatic({ + root: '.', + rewriteRequestPath: (path) => { + return join(scriptDir, path).replace(process.cwd(), ''); + }, + }) + ); + }; + + // Initialize static file serving + await setupStaticServing(); + + /** + * A helper function to enforce a timeout on SSE sends. + * @param fn A function that returns a Promise (e.g. stream.writeSSE()) + * @param timeoutMs The timeout in milliseconds (default: 2000) + */ + async function sendWithTimeout(fn: () => Promise, timeoutMs = 200) { + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + clearTimeout(id); + reject(new Error('Write timeout')); + }, timeoutMs); + }); + + return Promise.race([fn(), timeoutPromise]); + } app.get('/log/stream', (c: Context) => { const clientId = Date.now().toString(); @@ -37,29 +85,59 @@ if ( c.header('X-Accel-Buffering', 'no'); return streamSSE(c, async (stream) => { + const addLogClient: any = c.get('addLogClient'); + const removeLogClient: any = c.get('removeLogClient'); + const client = { - sendLog: (message: any) => stream.writeSSE(message), + sendLog: (message: any) => + sendWithTimeout(() => stream.writeSSE(message)), }; // Add this client to the set of log clients - const addLogClient: any = c.get('addLogClient'); - const removeLogClient: any = c.get('removeLogClient'); addLogClient(clientId, client); + // If the client disconnects (closes the tab, etc.), this signal will be aborted + const onAbort = () => { + removeLogClient(clientId); + }; + c.req.raw.signal.addEventListener('abort', onAbort); + try { // Send an initial connection event - await stream.writeSSE({ event: 'connected', data: clientId }); - - // Keep the connection open - while (true) { - await stream.sleep(10000); // Heartbeat every 10 seconds - await stream.writeSSE({ event: 'heartbeat', data: 'pulse' }); - } + await sendWithTimeout(() => + stream.writeSSE({ event: 'connected', data: clientId }) + ); + + // Use an interval instead of a while loop + const heartbeatInterval = setInterval(async () => { + if (c.req.raw.signal.aborted) { + clearInterval(heartbeatInterval); + return; + } + + try { + await sendWithTimeout(() => + stream.writeSSE({ event: 'heartbeat', data: 'pulse' }) + ); + } catch (error) { + // console.error(`Heartbeat failed for client ${clientId}:`, error); + clearInterval(heartbeatInterval); + removeLogClient(clientId); + } + }, 10000); + + // Wait for abort signal + await new Promise((resolve) => { + c.req.raw.signal.addEventListener('abort', () => { + clearInterval(heartbeatInterval); + resolve(undefined); + }); + }); } catch (error) { - console.error(`Error in log stream for client ${clientId}:`, error); - removeLogClient(clientId); + // console.error(`Error in log stream for client ${clientId}:`, error); } finally { // Remove this client when the connection is closed removeLogClient(clientId); + c.req.raw.signal.removeEventListener('abort', onAbort); } }); }); @@ -80,38 +158,41 @@ const server = serve({ const url = `http://localhost:${port}`; -// Function to open URL in the default browser -function openBrowser(url: string) { - let command: string; - // In Docker container, just log the URL in a clickable format - if (process.env.DOCKER || process.env.CONTAINER) { - console.log('\n🔗 Access your AI Gateway at: \x1b[36m%s\x1b[0m\n', url); - command = ''; // No-op for Docker/containers - } else { - switch (process.platform) { - case 'darwin': - command = `open ${url}`; - break; - case 'win32': - command = `start ${url}`; - break; - default: - command = `xdg-open ${url}`; - } - } +injectWebSocket(server); - if (command) { - exec(command, (error) => { - if (error) { - console.log('\n🔗 Access your AI Gateway at: \x1b[36m%s\x1b[0m\n', url); - } - }); - } +// Loading animation function +async function showLoadingAnimation() { + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + + return new Promise((resolve) => { + const interval = setInterval(() => { + process.stdout.write(`\r${frames[i]} Starting AI Gateway...`); + i = (i + 1) % frames.length; + }, 80); + + // Stop after 1 second + setTimeout(() => { + clearInterval(interval); + process.stdout.write('\r'); + resolve(undefined); + }, 1000); + }); } -// Open the browser only when --headless is not provided +// Clear the console and show animation before main output +console.clear(); +await showLoadingAnimation(); + +// Main server information with minimal spacing +console.log('\x1b[1m%s\x1b[0m', '🚀 Your AI Gateway is running at:'); +console.log(' ' + '\x1b[1;4;32m%s\x1b[0m', `${url}`); + +// Secondary information on single lines if (!isHeadless) { - openBrowser(`${url}/public/`); + console.log('\n\x1b[90m📱 UI:\x1b[0m \x1b[36m%s\x1b[0m', `${url}/public/`); } -injectWebSocket(server); -console.log(`Your AI Gateway is now running on http://localhost:${port} 🚀`); +// console.log('\x1b[90m📚 Docs:\x1b[0m \x1b[36m%s\x1b[0m', 'https://portkey.ai/docs'); + +// Single-line ready message +console.log('\n\x1b[32m✨ Ready for connections!\x1b[0m');