From dcacae6f21084b871efb687d5e1617f8679b72e2 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Fri, 13 Feb 2026 21:59:38 +0100 Subject: [PATCH 1/3] Add CORS headers for cross-origin requests to app builder preview subdomains The keep-alive pings from app.kilo.ai to *.builder.kiloapps.io were blocked by the browser due to missing CORS headers. This adds Access-Control-Allow-Origin headers (with preflight support) for origins specified in the ALLOWED_ORIGINS env var. --- cloudflare-app-builder/src/index.ts | 32 ++++++++++++++++++- .../worker-configuration.d.ts | 2 ++ cloudflare-app-builder/wrangler.jsonc | 1 + 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cloudflare-app-builder/src/index.ts b/cloudflare-app-builder/src/index.ts index fbe5446ab..995bc123d 100644 --- a/cloudflare-app-builder/src/index.ts +++ b/cloudflare-app-builder/src/index.ts @@ -62,6 +62,28 @@ function extractAppIdFromPath(pathname: string): string | null { return match ? match[1] : null; } +/** + * Return the origin if it's in the allow-list, otherwise null. + */ +function getAllowedOrigin(request: Request, env: Env): string | null { + const origin = request.headers.get('Origin'); + if (!origin || !env.ALLOWED_ORIGINS) return null; + const allowed = env.ALLOWED_ORIGINS.split(','); + return allowed.includes(origin) ? origin : null; +} + +/** + * Append CORS headers to an existing response for an allowed origin. + */ +function withCorsHeaders(response: Response, origin: string): Response { + const newResponse = new Response(response.body, response); + newResponse.headers.set('Access-Control-Allow-Origin', origin); + newResponse.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, POST, PUT, DELETE, OPTIONS'); + newResponse.headers.set('Access-Control-Allow-Headers', 'Content-Type'); + newResponse.headers.set('Vary', 'Origin'); + return newResponse; +} + /** * Main worker fetch handler */ @@ -86,8 +108,16 @@ export default { }); if (subdomainAppId) { + const allowedOrigin = getAllowedOrigin(request, env); + + // Handle CORS preflight for cross-origin requests (e.g. keep-alive pings from app.kilo.ai) + if (allowedOrigin && request.method === 'OPTIONS') { + return withCorsHeaders(new Response(null, { status: 204 }), allowedOrigin); + } + // All requests to app-id.* subdomain are proxied to the preview sandbox - return handlePreviewProxy(request, env, subdomainAppId); + const response = await handlePreviewProxy(request, env, subdomainAppId); + return allowedOrigin ? withCorsHeaders(response, allowedOrigin) : response; } // Handle init requests diff --git a/cloudflare-app-builder/worker-configuration.d.ts b/cloudflare-app-builder/worker-configuration.d.ts index e12ddf2dc..f677c9633 100644 --- a/cloudflare-app-builder/worker-configuration.d.ts +++ b/cloudflare-app-builder/worker-configuration.d.ts @@ -9,6 +9,7 @@ declare namespace Cloudflare { interface Env { BACKEND_PUSH_NOTIFICATION_URL: 'https://app.kilo.ai/api/app-builder/push-notification'; GIT_TOKEN_EXPIRY_SECONDS: '21600'; + ALLOWED_ORIGINS: string; AUTH_TOKEN: string; BUILDER_HOSTNAME: string; GIT_JWT_SECRET: string; @@ -32,6 +33,7 @@ declare namespace NodeJS { Cloudflare.Env, | 'BACKEND_PUSH_NOTIFICATION_URL' | 'GIT_TOKEN_EXPIRY_SECONDS' + | 'ALLOWED_ORIGINS' | 'AUTH_TOKEN' | 'BUILDER_HOSTNAME' | 'GIT_JWT_SECRET' diff --git a/cloudflare-app-builder/wrangler.jsonc b/cloudflare-app-builder/wrangler.jsonc index 5a7137a96..4efbdcc6e 100644 --- a/cloudflare-app-builder/wrangler.jsonc +++ b/cloudflare-app-builder/wrangler.jsonc @@ -16,6 +16,7 @@ "BACKEND_PUSH_NOTIFICATION_URL": "https://app.kilo.ai/api/app-builder/push-notification", "GIT_TOKEN_EXPIRY_SECONDS": "21600", "DB_PROXY_URL": "https://db-proxy.engineering-e11.workers.dev", + "ALLOWED_ORIGINS": "https://app.kilo.ai", }, "routes": [ { From 6ef3fd3601f6bb9bd4256b413c78a47c83186dcf Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 16 Feb 2026 09:38:51 +0100 Subject: [PATCH 2/3] Replace polling-based preview status with SSE real-time events - Add SSE endpoint (/apps/{id}/events) with JWT ticket auth for real-time preview status, build logs, errors, and container lifecycle events - Add fan-out architecture in PreviewDO: eventWriters set, broadcastEvent(), subscribeEvents() with 30s keepalive to prevent proxy timeout - Add frontend usePreviewEvents hook with fetch-based SSE parsing, exponential backoff reconnection, and fresh ticket on each reconnect - Add getEventsTicket tRPC query (personal + org) for JWT ticket issuance - Replace triggerBuild() with onGitPush() for push webhooks: defers build when no SSE clients are connected (pendingBuild flag), triggers on reconnect - Add SleepingState and GeneratingState UI components in AppBuilderPreview - Add AppBuilderSandbox subclass with onStop() hook for container sleep detection - Remove preview-polling.ts, its tests, and streamBuildLogs endpoint (merged into /events) --- cloudflare-app-builder/src/api-schemas.ts | 14 - .../src/app-builder-sandbox.ts | 65 ++++ cloudflare-app-builder/src/handlers/events.ts | 76 ++++ .../src/handlers/git-protocol.ts | 4 +- .../src/handlers/preview.ts | 33 +- cloudflare-app-builder/src/index.ts | 26 +- cloudflare-app-builder/src/preview-do.ts | 209 +++++++++-- cloudflare-app-builder/src/types.ts | 42 ++- cloudflare-app-builder/src/utils/auth.ts | 32 ++ .../worker-configuration.d.ts | 2 + cloudflare-app-builder/wrangler.jsonc | 10 +- .../app-builder/AppBuilderPreview.tsx | 104 ++++-- src/components/app-builder/ProjectManager.ts | 116 ++++-- .../__tests__/preview-polling.test.ts | 345 ------------------ .../project-manager/preview-polling.ts | 136 ------- .../app-builder/project-manager/types.ts | 15 +- .../project-manager/usePreviewEvents.ts | 287 +++++++++++++++ src/lib/app-builder/app-builder-client.ts | 31 +- src/lib/app-builder/events-ticket.ts | 32 ++ src/lib/config.server.ts | 2 + .../github/webhook-handlers/push-handler.ts | 4 +- src/routers/app-builder-router.ts | 14 + .../organization-app-builder-router.ts | 13 + 23 files changed, 937 insertions(+), 675 deletions(-) create mode 100644 cloudflare-app-builder/src/app-builder-sandbox.ts create mode 100644 cloudflare-app-builder/src/handlers/events.ts delete mode 100644 src/components/app-builder/project-manager/__tests__/preview-polling.test.ts delete mode 100644 src/components/app-builder/project-manager/preview-polling.ts create mode 100644 src/components/app-builder/project-manager/usePreviewEvents.ts create mode 100644 src/lib/app-builder/events-ticket.ts diff --git a/cloudflare-app-builder/src/api-schemas.ts b/cloudflare-app-builder/src/api-schemas.ts index 9c1b452ab..196a13fd1 100644 --- a/cloudflare-app-builder/src/api-schemas.ts +++ b/cloudflare-app-builder/src/api-schemas.ts @@ -82,20 +82,6 @@ export const BuildTriggerErrorResponseSchema = z.object({ export type BuildTriggerErrorResponse = z.infer; -// ============================================ -// Build Logs Streaming Endpoint Schemas -// GET /apps/{app_id}/build/logs -// ============================================ - -// Returns Server-Sent Events stream on success -// Returns error response on failure -export const BuildLogsErrorResponseSchema = z.object({ - error: z.enum(['no_logs_available', 'internal_error']), - message: z.string(), -}); - -export type BuildLogsErrorResponse = z.infer; - // ============================================ // Token Generation Endpoint Schemas // POST /apps/{app_id}/token diff --git a/cloudflare-app-builder/src/app-builder-sandbox.ts b/cloudflare-app-builder/src/app-builder-sandbox.ts new file mode 100644 index 000000000..6a54e2b2e --- /dev/null +++ b/cloudflare-app-builder/src/app-builder-sandbox.ts @@ -0,0 +1,65 @@ +/** + * Custom Sandbox subclass with lifecycle hooks. + * + * Hooks into container stop events so PreviewDO can track when the container + * goes to sleep, avoiding expensive getSandboxState() calls that would wake it. + */ + +import { Sandbox } from '@cloudflare/sandbox'; +import type { Env } from './types'; +import { logger } from './utils/logger'; + +// StopParams from @cloudflare/containers -- not re-exported by @cloudflare/sandbox +// but the runtime passes this object to onStop regardless of the declared signature. +type StopParams = { + exitCode: number; + reason: 'exit' | 'runtime_signal'; +}; + +export class AppBuilderSandbox extends Sandbox { + private get sandboxId(): string { + return this.ctx.id.name ?? this.ctx.id.toString(); + } + + override onStart(): void { + super.onStart(); + logger.info('[lifecycle] Container started', { sandboxId: this.sandboxId }); + } + + /** + * Sandbox declares onStop() with no params, but the Container base class + * (and the runtime) pass StopParams. We accept no params to match the + * parent signature, then read the actual params via `arguments`. + */ + override async onStop(): Promise { + await super.onStop(); + // eslint-disable-next-line prefer-rest-params + const params = arguments[0] as StopParams | undefined; + const appId = this.sandboxId; + logger.info('[lifecycle] Container stopped', { + sandboxId: appId, + exitCode: params?.exitCode, + reason: params?.reason, + }); + + // Notify the PreviewDO that the container stopped. + // The sandbox name IS the appId (getSandbox(env.SANDBOX, appId)). + try { + const previewStub = this.env.PREVIEW.get(this.env.PREVIEW.idFromName(appId)); + await previewStub.handleContainerStopped(); + } catch (err) { + logger.error('[lifecycle] Failed to notify PreviewDO on stop', { + sandboxId: appId, + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + override onError(error: unknown): void { + super.onError(error); + logger.error('[lifecycle] Container error', { + sandboxId: this.sandboxId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} diff --git a/cloudflare-app-builder/src/handlers/events.ts b/cloudflare-app-builder/src/handlers/events.ts new file mode 100644 index 000000000..9492c5427 --- /dev/null +++ b/cloudflare-app-builder/src/handlers/events.ts @@ -0,0 +1,76 @@ +import { logger, formatError } from '../utils/logger'; +import { verifyEventTicket } from '../utils/auth'; +import type { Env } from '../types'; +import type { PreviewDO } from '../preview-do'; + +function getPreviewDO(appId: string, env: Env): DurableObjectStub { + const id = env.PREVIEW.idFromName(appId); + return env.PREVIEW.get(id); +} + +/** + * Handle SSE event stream requests. + * Authenticates via JWT ticket (query param), then subscribes to PreviewDO events. + * + * GET /apps/{appId}/events?ticket=xxx + */ +export async function handleEvents(request: Request, env: Env, appId: string): Promise { + try { + const url = new URL(request.url); + const ticket = url.searchParams.get('ticket'); + + if (!ticket) { + return new Response(JSON.stringify({ error: 'missing_ticket' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!env.APP_BUILDER_TICKET_SECRET) { + logger.error('APP_BUILDER_TICKET_SECRET not configured'); + return new Response(JSON.stringify({ error: 'internal_error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const result = verifyEventTicket(ticket, env.APP_BUILDER_TICKET_SECRET); + if (!result.valid) { + return new Response(JSON.stringify({ error: 'invalid_ticket', message: result.error }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Verify the ticket's projectId matches the URL's appId + if (result.projectId !== appId) { + return new Response(JSON.stringify({ error: 'ticket_project_mismatch' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const previewStub = getPreviewDO(appId, env); + const stream = await previewStub.subscribeEvents(); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } catch (error) { + logger.error('Events handler error', formatError(error)); + return new Response( + JSON.stringify({ + error: 'internal_error', + message: error instanceof Error ? error.message : 'Unknown error', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ); + } +} diff --git a/cloudflare-app-builder/src/handlers/git-protocol.ts b/cloudflare-app-builder/src/handlers/git-protocol.ts index e07c89cae..3039085ad 100644 --- a/cloudflare-app-builder/src/handlers/git-protocol.ts +++ b/cloudflare-app-builder/src/handlers/git-protocol.ts @@ -344,9 +344,9 @@ async function handleReceivePack( const previewStub = env.PREVIEW.get(previewId); ctx.waitUntil( - previewStub.triggerBuild().catch(error => { + previewStub.onGitPush().catch(error => { // Log error but don't fail the push - logger.error('Failed to trigger preview build', formatError(error)); + logger.error('Failed to notify preview of git push', formatError(error)); }) ); } diff --git a/cloudflare-app-builder/src/handlers/preview.ts b/cloudflare-app-builder/src/handlers/preview.ts index 189b7981e..0df3e0eb2 100644 --- a/cloudflare-app-builder/src/handlers/preview.ts +++ b/cloudflare-app-builder/src/handlers/preview.ts @@ -216,13 +216,8 @@ export async function handleTriggerBuild( } } -export async function handleStreamBuildLogs( - request: Request, - env: Env, - appId: string -): Promise { +export async function handleGitPush(request: Request, env: Env, appId: string): Promise { try { - // 1. Verify Bearer token authentication const authResult = verifyBearerToken(request, env); if (!authResult.isAuthenticated) { if (!authResult.errorResponse) { @@ -232,31 +227,13 @@ export async function handleStreamBuildLogs( } const previewStub = getPreviewDO(appId, env); - const logStream = await previewStub.streamBuildLogs(); + await previewStub.onGitPush(); - if (!logStream) { - return new Response( - JSON.stringify({ - error: 'no_logs_available', - message: 'No build process is currently running or process ID not available', - }), - { - status: 404, - headers: { 'Content-Type': 'application/json' }, - } - ); - } - - // Return the stream as Server-Sent Events - return new Response(logStream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, + return new Response('', { + status: 202, // Accepted - build will run if user is connected }); } catch (error) { - logger.error('Stream build logs error', formatError(error)); + logger.error('Git push notification error', formatError(error)); return new Response( JSON.stringify({ error: 'internal_error', diff --git a/cloudflare-app-builder/src/index.ts b/cloudflare-app-builder/src/index.ts index 995bc123d..e1d407b82 100644 --- a/cloudflare-app-builder/src/index.ts +++ b/cloudflare-app-builder/src/index.ts @@ -1,6 +1,6 @@ import { GitRepositoryDO } from './git-repository-do'; import { PreviewDO } from './preview-do'; -import { Sandbox } from '@cloudflare/sandbox'; +import { AppBuilderSandbox } from './app-builder-sandbox'; import type { Env } from './types'; import { handleGitProtocolRequest, isGitProtocolRequest } from './handlers/git-protocol'; import { handleInit } from './handlers/init'; @@ -10,14 +10,15 @@ import { handleGetCommit } from './handlers/commit'; import { handleMigrateToGithub } from './handlers/migrate-to-github'; import { handleGetPreviewStatus, + handleGitPush, handlePreviewProxy, - handleStreamBuildLogs, handleTriggerBuild, } from './handlers/preview'; +import { handleEvents } from './handlers/events'; import { logger, withLogTags } from './utils/logger'; // Export Durable Objects -export { GitRepositoryDO, PreviewDO, Sandbox }; +export { GitRepositoryDO, PreviewDO, AppBuilderSandbox }; // Route patterns const APP_ID_PATTERN_STR = '[a-z0-9_-]{20,}'; @@ -27,7 +28,8 @@ const TOKEN_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/token$`); const COMMIT_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/commit$`); const PREVIEW_STATUS_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/preview$`); const BUILD_TRIGGER_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/build$`); -const BUILD_LOGS_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/build/logs$`); +const EVENTS_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/events$`); +const GIT_PUSH_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/push$`); const MIGRATE_TO_GITHUB_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})/migrate-to-github$`); const DELETE_PATTERN = new RegExp(`^/apps/(${APP_ID_PATTERN_STR})$`); @@ -110,7 +112,7 @@ export default { if (subdomainAppId) { const allowedOrigin = getAllowedOrigin(request, env); - // Handle CORS preflight for cross-origin requests (e.g. keep-alive pings from app.kilo.ai) + // Handle CORS preflight for cross-origin requests from app.kilo.ai if (allowedOrigin && request.method === 'OPTIONS') { return withCorsHeaders(new Response(null, { status: 204 }), allowedOrigin); } @@ -154,10 +156,16 @@ export default { return handleTriggerBuild(request, env, buildTriggerMatch[1]); } - // Handle build logs streaming requests (GET /apps/{app_id}/build/logs) - const buildLogsMatch = pathname.match(BUILD_LOGS_PATTERN); - if (buildLogsMatch && request.method === 'GET') { - return handleStreamBuildLogs(request, env, buildLogsMatch[1]); + // Handle git push notifications (POST /apps/{app_id}/push) + const gitPushMatch = pathname.match(GIT_PUSH_PATTERN); + if (gitPushMatch && request.method === 'POST') { + return handleGitPush(request, env, gitPushMatch[1]); + } + + // Handle SSE event stream (GET /apps/{app_id}/events) + const eventsMatch = pathname.match(EVENTS_PATTERN); + if (eventsMatch && request.method === 'GET') { + return handleEvents(request, env, eventsMatch[1]); } // Handle migrate to GitHub requests (POST /apps/{app_id}/migrate-to-github) diff --git a/cloudflare-app-builder/src/preview-do.ts b/cloudflare-app-builder/src/preview-do.ts index 1e559ff2a..a0ddd48f5 100644 --- a/cloudflare-app-builder/src/preview-do.ts +++ b/cloudflare-app-builder/src/preview-do.ts @@ -9,6 +9,7 @@ import { type PreviewPersistedState, type GitHubSourceConfig, type GetTokenForRepoResult, + type AppBuilderEvent, DEFAULT_SANDBOX_PORT, } from './types'; import { logger, withLogTags, formatError } from './utils/logger'; @@ -22,6 +23,15 @@ export class PreviewDO extends DurableObject { private persistedState: PreviewPersistedState; /** Guard to prevent re-entry of _executeBuild while a build is in progress */ private isBuildInProgress = false; + /** Connected SSE writers for event fan-out */ + private eventWriters = new Set>(); + /** Chains writes per writer to avoid violating the WritableStream contract */ + private writerQueues = new Map, Promise>(); + /** Keepalive interval IDs per writer */ + private keepaliveIntervals = new Map< + WritableStreamDefaultWriter, + ReturnType + >(); constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); @@ -32,6 +42,8 @@ export class PreviewDO extends DurableObject { lastError: null, dbCredentials: null, githubSource: null, + containerStopped: false, + pendingBuild: false, }; // Restore persisted state from storage if available @@ -239,6 +251,12 @@ export class PreviewDO extends DurableObject { // Log output for debugging (verbose, use debug level) if (data) { logger.debug('Sandbox output', { data }); + this.broadcastEvent({ + type: 'log', + source: 'build', + message: data, + timestamp: new Date().toISOString(), + }); } if (event.type === 'error') { @@ -264,6 +282,92 @@ export class PreviewDO extends DurableObject { return isOpen; } + // ============================================ + // SSE Event Fan-Out + // ============================================ + + private sseEncode(event: AppBuilderEvent): Uint8Array { + const data = JSON.stringify(event); + return new TextEncoder().encode(`event: ${event.type}\ndata: ${data}\n\n`); + } + + /** Enqueue a write for a specific writer, chaining after any pending write. */ + private enqueueWrite(writer: WritableStreamDefaultWriter, data: Uint8Array): void { + const prev = this.writerQueues.get(writer) ?? Promise.resolve(); + const next = prev + .then(() => writer.ready) + .then(() => writer.write(data)) + .catch(() => { + this.removeWriter(writer); + }); + this.writerQueues.set(writer, next); + } + + private broadcastEvent(event: AppBuilderEvent): void { + const encoded = this.sseEncode(event); + for (const writer of this.eventWriters) { + this.enqueueWrite(writer, encoded); + } + } + + private removeWriter(writer: WritableStreamDefaultWriter): void { + this.eventWriters.delete(writer); + this.writerQueues.delete(writer); + const interval = this.keepaliveIntervals.get(writer); + if (interval) { + clearInterval(interval); + this.keepaliveIntervals.delete(writer); + } + try { + writer.close().catch(() => {}); + } catch { + // Already closed + } + } + + /** + * Subscribe to SSE events. Returns a ReadableStream that emits SSE-formatted events. + * Sends current state immediately, then broadcasts future events. + * Keeps connection alive with periodic comments. + */ + async subscribeEvents(): Promise> { + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + this.eventWriters.add(writer); + + // Send current state immediately + const { state, error } = await this.getStatus(); + const previewUrl = + state === 'running' && this.persistedState.appId + ? `https://${this.persistedState.appId}.${this.env.BUILDER_HOSTNAME}` + : undefined; + + const initialEvent: AppBuilderEvent = { type: 'status', state, previewUrl }; + this.enqueueWrite(writer, this.sseEncode(initialEvent)); + + if (error) { + const errorEvent: AppBuilderEvent = { type: 'error', message: error }; + this.enqueueWrite(writer, this.sseEncode(errorEvent)); + } + + // Start keepalive interval (30s) + const keepaliveComment = new TextEncoder().encode(': keepalive\n\n'); + const interval = setInterval(() => { + this.enqueueWrite(writer, keepaliveComment); + }, 30_000); + this.keepaliveIntervals.set(writer, interval); + + // If a build was deferred while no clients were connected, trigger it now + if (this.persistedState.pendingBuild) { + this.persistedState.pendingBuild = false; + await this.savePersistedState(); + logger.info('Pending build triggered on SSE reconnect'); + this.ctx.waitUntil(this._executeBuild()); + } + + return readable; + } + // ============================================ // RPC Methods (Public API) // ============================================ @@ -324,6 +428,11 @@ export class PreviewDO extends DurableObject { return withLogTags( { source: 'PreviewDO', tags: { appId: this.persistedState.appId ?? undefined } }, async () => { + // Fast path: if the container stopped, return 'sleeping' without waking it + if (this.persistedState.containerStopped) { + return { state: 'sleeping', error: this.persistedState.lastError }; + } + const state = await this.getSandboxState(); return { state, @@ -333,6 +442,54 @@ export class PreviewDO extends DurableObject { ); } + /** + * Called by AppBuilderSandbox.onStop() when the container goes to sleep. + * Sets the containerStopped flag so getStatus() can return 'sleeping' + * without waking the container. + */ + async handleContainerStopped(): Promise { + return withLogTags( + { source: 'PreviewDO', tags: { appId: this.persistedState.appId ?? undefined } }, + async () => { + logger.info('Container stopped, marking as sleeping'); + this.persistedState.containerStopped = true; + await this.savePersistedState(); + + this.broadcastEvent({ type: 'container-stopped' }); + this.broadcastEvent({ type: 'status', state: 'sleeping' }); + + // Close all writers — clients will reconnect when needed + for (const writer of this.eventWriters) { + this.removeWriter(writer); + } + } + ); + } + + /** + * Handle a git push event. Builds immediately if SSE clients are connected, + * otherwise defers by setting pendingBuild flag (cleared on next subscribeEvents). + */ + async onGitPush(): Promise { + return withLogTags( + { source: 'PreviewDO', tags: { appId: this.persistedState.appId ?? undefined } }, + async () => { + if (!this.persistedState.appId) { + throw new Error('App ID not set'); + } + + if (this.eventWriters.size > 0) { + logger.info('Git push — active connections, building immediately'); + this.ctx.waitUntil(this._executeBuild()); + } else { + logger.info('Build deferred — no active connections'); + this.persistedState.pendingBuild = true; + await this.savePersistedState(); + } + } + ); + } + /** * Trigger a build (fire-and-forget) * @@ -369,8 +526,13 @@ export class PreviewDO extends DurableObject { throw new Error('App ID not set'); } - // Clear any previous error state + // Clear any previous error state, container stopped flag, and pending build flag await this.clearErrorState(); + this.persistedState.containerStopped = false; + this.persistedState.pendingBuild = false; + await this.savePersistedState(); + + this.broadcastEvent({ type: 'status', state: 'building' }); const sandbox = getSandbox(this.env.SANDBOX, appId); @@ -469,11 +631,18 @@ export class PreviewDO extends DurableObject { }); logger.info('Dev server started', { processId }); + this.broadcastEvent({ + type: 'status', + state: 'running', + previewUrl: `https://${appId}.${this.env.BUILDER_HOSTNAME}`, + }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error occurred'; this.persistedState.lastError = message; await this.savePersistedState(); + this.broadcastEvent({ type: 'error', message }); + this.broadcastEvent({ type: 'status', state: 'error' }); logger.error('Build execution failed', formatError(error)); throw error; } finally { @@ -483,37 +652,6 @@ export class PreviewDO extends DurableObject { ); } - /** - * Stream build logs from the dev server process - * Returns a ReadableStream that emits SSE log events - */ - async streamBuildLogs(): Promise { - return withLogTags( - { source: 'PreviewDO', tags: { appId: this.persistedState.appId ?? undefined } }, - async () => { - if (!this.persistedState.appId) { - return null; - } - - try { - const sandbox = getSandbox(this.env.SANDBOX, this.persistedState.appId); - - // Find the active dev server process - const process = await this.getDevProcess(sandbox); - if (!process) { - return null; - } - - const logStream = (await sandbox.streamProcessLogs(process.id)) as ReadableStream; - return logStream; - } catch (error) { - logger.error('Failed to stream build logs', formatError(error)); - return null; - } - } - ); - } - /** * Destroy the sandbox container */ @@ -546,6 +684,11 @@ export class PreviewDO extends DurableObject { async () => { logger.info('Deleting all preview data'); + // Close all SSE event writers + for (const writer of this.eventWriters) { + this.removeWriter(writer); + } + // First destroy the sandbox container if it exists await this.destroy(); @@ -558,6 +701,8 @@ export class PreviewDO extends DurableObject { lastError: null, dbCredentials: null, githubSource: null, + containerStopped: false, + pendingBuild: false, }; logger.info('Preview deleted successfully'); diff --git a/cloudflare-app-builder/src/types.ts b/cloudflare-app-builder/src/types.ts index c787accbd..e338819e6 100644 --- a/cloudflare-app-builder/src/types.ts +++ b/cloudflare-app-builder/src/types.ts @@ -154,7 +154,7 @@ export interface ErrnoException extends Error { * - running: dev server process exists AND port is open * - error: process failed/killed (auto-clears on next build) */ -export type PreviewState = 'uninitialized' | 'idle' | 'building' | 'running' | 'error'; +export type PreviewState = 'uninitialized' | 'idle' | 'building' | 'running' | 'error' | 'sleeping'; /** * Database credentials - both fields are always present together @@ -183,6 +183,12 @@ export type PreviewPersistedState = { dbCredentials: DbCredentials | null; // GitHub migration state - when set, clone from GitHub instead of internal repo githubSource: GitHubSourceConfig | null; + // Set to true by onStop() lifecycle hook when the container goes to sleep. + // Allows getStatus() to return 'sleeping' without waking the container. + containerStopped: boolean; + // Set to true by onGitPush() when no SSE clients are connected. + // Cleared and build triggered when a client connects via subscribeEvents(). + pendingBuild: boolean; }; /** @@ -195,3 +201,37 @@ export interface PreviewStatusResponse { error: string | null; appId: string; } + +// ============================================ +// SSE Event Types +// ============================================ + +type StatusEvent = { + type: 'status'; + state: PreviewState; + previewUrl?: string; +}; + +type LogEvent = { + type: 'log'; + source: 'build' | 'dev-server'; + message: string; + timestamp: string; +}; + +type ErrorEvent = { + type: 'error'; + message: string; +}; + +type ContainerStoppedEvent = { + type: 'container-stopped'; +}; + +export type AppBuilderEvent = StatusEvent | LogEvent | ErrorEvent | ContainerStoppedEvent; + +export type EventTicketPayload = { + type: 'app_builder_event'; + userId: string; + projectId: string; +}; diff --git a/cloudflare-app-builder/src/utils/auth.ts b/cloudflare-app-builder/src/utils/auth.ts index 13082abf2..ae1ba96fb 100644 --- a/cloudflare-app-builder/src/utils/auth.ts +++ b/cloudflare-app-builder/src/utils/auth.ts @@ -1,5 +1,7 @@ import type { Env } from '../types'; import { verifyGitToken, type GitTokenPermission } from './jwt'; +import * as jwt from 'jsonwebtoken'; +import { z } from 'zod'; export type AuthResult = { isAuthenticated: boolean; @@ -205,3 +207,33 @@ export async function verifyGitAuth( }), }; } + +// ============================================================================= +// Event Ticket Verification +// ============================================================================= + +const eventTicketPayloadSchema = z.object({ + type: z.literal('app_builder_event'), + userId: z.string(), + projectId: z.string(), +}); + +export type EventTicketResult = + | { valid: true; userId: string; projectId: string } + | { valid: false; error: string }; + +export function verifyEventTicket(ticket: string, secret: string): EventTicketResult { + try { + const payload = jwt.verify(ticket, secret, { algorithms: ['HS256'] }); + const parsed = eventTicketPayloadSchema.parse(payload); + return { valid: true, userId: parsed.userId, projectId: parsed.projectId }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return { valid: false, error: 'Ticket expired' }; + } + if (error instanceof jwt.JsonWebTokenError) { + return { valid: false, error: 'Invalid ticket' }; + } + return { valid: false, error: 'Invalid ticket format' }; + } +} diff --git a/cloudflare-app-builder/worker-configuration.d.ts b/cloudflare-app-builder/worker-configuration.d.ts index f677c9633..b71ea13e2 100644 --- a/cloudflare-app-builder/worker-configuration.d.ts +++ b/cloudflare-app-builder/worker-configuration.d.ts @@ -10,6 +10,7 @@ declare namespace Cloudflare { BACKEND_PUSH_NOTIFICATION_URL: 'https://app.kilo.ai/api/app-builder/push-notification'; GIT_TOKEN_EXPIRY_SECONDS: '21600'; ALLOWED_ORIGINS: string; + APP_BUILDER_TICKET_SECRET: string; AUTH_TOKEN: string; BUILDER_HOSTNAME: string; GIT_JWT_SECRET: string; @@ -34,6 +35,7 @@ declare namespace NodeJS { | 'BACKEND_PUSH_NOTIFICATION_URL' | 'GIT_TOKEN_EXPIRY_SECONDS' | 'ALLOWED_ORIGINS' + | 'APP_BUILDER_TICKET_SECRET' | 'AUTH_TOKEN' | 'BUILDER_HOSTNAME' | 'GIT_JWT_SECRET' diff --git a/cloudflare-app-builder/wrangler.jsonc b/cloudflare-app-builder/wrangler.jsonc index 4efbdcc6e..ff8c5f314 100644 --- a/cloudflare-app-builder/wrangler.jsonc +++ b/cloudflare-app-builder/wrangler.jsonc @@ -17,6 +17,8 @@ "GIT_TOKEN_EXPIRY_SECONDS": "21600", "DB_PROXY_URL": "https://db-proxy.engineering-e11.workers.dev", "ALLOWED_ORIGINS": "https://app.kilo.ai", + // Set via `wrangler secret put` in production + "APP_BUILDER_TICKET_SECRET": "", }, "routes": [ { @@ -49,7 +51,7 @@ "name": "PREVIEW", }, { - "class_name": "Sandbox", + "class_name": "AppBuilderSandbox", "name": "SANDBOX", }, ], @@ -74,7 +76,7 @@ ], "containers": [ { - "class_name": "Sandbox", + "class_name": "AppBuilderSandbox", "image": "./Dockerfile", "instance_type": "standard-4", "max_instances": 170, @@ -85,6 +87,10 @@ "new_sqlite_classes": ["GitRepositoryDO", "PreviewDO", "Sandbox"], "tag": "v1", }, + { + "renamed_classes": [{ "from": "Sandbox", "to": "AppBuilderSandbox" }], + "tag": "v2", + }, ], "workers_dev": false, "preview_urls": false, diff --git a/src/components/app-builder/AppBuilderPreview.tsx b/src/components/app-builder/AppBuilderPreview.tsx index 3567a446e..979b88857 100644 --- a/src/components/app-builder/AppBuilderPreview.tsx +++ b/src/components/app-builder/AppBuilderPreview.tsx @@ -22,6 +22,8 @@ import { Copy, Check, Github, + Moon, + Sparkles, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -92,6 +94,43 @@ function ErrorState({ onRetry }: { onRetry?: () => void }) { ); } +function SleepingState({ onResume }: { onResume: () => void }) { + return ( +
+
+ +
+

Preview paused

+

+ The preview went to sleep after being idle. Click Resume to restart it. +

+ +
+ ); +} + +function GeneratingState() { + return ( +
+
+ +
+

Generating your app

+

+ The AI is writing code for your project. The preview will appear once it's ready. +

+
+
+
+
+
+
+ ); +} + /** * Loading overlay shown while iframe content is loading */ @@ -386,7 +425,14 @@ export const AppBuilderPreview = memo(function AppBuilderPreview({ }: AppBuilderPreviewProps) { // Get state and manager from ProjectSession context const { manager, state } = useProject(); - const { previewUrl, previewStatus, deploymentId, currentIframeUrl, gitRepoFullName } = state; + const { + previewUrl, + previewStatus, + isStreaming, + deploymentId, + currentIframeUrl, + gitRepoFullName, + } = state; const projectId = manager.projectId; const isMigrated = Boolean(gitRepoFullName); @@ -470,35 +516,6 @@ export const AppBuilderPreview = memo(function AppBuilderPreview({ const buildStatus = deploymentData?.latestBuild?.status; const deploymentUrl = deploymentData?.deployment?.deployment_url; - // Periodic ping to keep sandbox alive (pauses when tab is hidden) - useEffect(() => { - if (previewStatus !== 'running' || !previewUrl) return; - - const ping = () => void fetch(previewUrl, { method: 'HEAD' }).catch(() => {}); - - let intervalId: ReturnType | null = null; - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - if (!intervalId) { - ping(); - intervalId = setInterval(ping, 20000); - } - } else if (intervalId) { - clearInterval(intervalId); - intervalId = null; - } - }; - - handleVisibilityChange(); - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - if (intervalId) clearInterval(intervalId); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [previewUrl, previewStatus]); - const handleRefresh = useCallback(() => { manager.setCurrentIframeUrl(null); setIframeKey(prev => prev + 1); @@ -545,6 +562,11 @@ export const AppBuilderPreview = memo(function AppBuilderPreview({ return false; }, [currentIframeUrl, previewUrl]); + // Handle resuming from sleeping state + const handleResume = useCallback(() => { + manager.resumeFromSleep(); + }, [manager]); + // Handle deploy using ProjectManager const handleDeploy = useCallback(async () => { setIsCreatingDeployment(true); @@ -609,13 +631,13 @@ export const AppBuilderPreview = memo(function AppBuilderPreview({
- {/* Content */} + {/* Content — exactly one state renders at a time */}
- {previewStatus === 'idle' && } - {previewStatus === 'building' && } - {previewStatus === 'error' && } - {previewStatus === 'running' && !previewUrl && } - {previewStatus === 'running' && previewUrl && ( + {previewStatus === 'error' ? ( + + ) : previewStatus === 'sleeping' ? ( + + ) : previewStatus === 'running' && previewUrl ? ( - )} + ) : previewStatus === 'running' && !previewUrl ? ( + + ) : isStreaming && !previewUrl ? ( + + ) : previewStatus === 'building' ? ( + + ) : previewStatus === 'idle' ? ( + + ) : null}
); diff --git a/src/components/app-builder/ProjectManager.ts b/src/components/app-builder/ProjectManager.ts index b227de027..bd3d2fed9 100644 --- a/src/components/app-builder/ProjectManager.ts +++ b/src/components/app-builder/ProjectManager.ts @@ -8,45 +8,36 @@ * - store.ts: State management and subscriber notifications * - messages.ts: Message creation and version tracking * - streaming.ts: WebSocket-based streaming coordination (V2 API) - * - preview-polling.ts: Preview status polling and build triggers + * - usePreviewEvents.ts: SSE-based real-time preview events * - deployments.ts: Production deployment logic * - logging.ts: Prefixed console logging */ import { type TRPCClient } from '@trpc/client'; import type { RootRouter } from '@/routers/root-router'; -import type { CloudMessage } from '@/components/cloud-agent/types'; import type { DeployProjectResult, ProjectWithMessages } from '@/lib/app-builder/types'; import type { Images } from '@/lib/images-schema'; import { createLogger, type Logger } from './project-manager/logging'; import { createProjectStore, createInitialState } from './project-manager/store'; -import type { ProjectStore, V2StreamingCoordinator } from './project-manager/types'; -import { startPreviewPolling, type PreviewPollingState } from './project-manager/preview-polling'; +import type { + ProjectStore, + V2StreamingCoordinator, + PreviewStatus, + ProjectState, +} from './project-manager/types'; +import { startPreviewEvents } from './project-manager/usePreviewEvents'; +import type { PreviewEventsHandle } from './project-manager/types'; import { createStreamingCoordinator } from './project-manager/streaming'; import { deploy as deployProject } from './project-manager/deployments'; +export type { PreviewStatus, ProjectState }; + // ============================================================================= // Type Definitions // ============================================================================= type AppTRPCClient = TRPCClient; -export type PreviewStatus = 'idle' | 'building' | 'running' | 'error'; - -export type ProjectState = { - messages: CloudMessage[]; - isStreaming: boolean; - isInterrupting: boolean; - previewUrl: string | null; - previewStatus: PreviewStatus; - deploymentId: string | null; - model: string; - /** Current URL the user is viewing in the preview iframe (tracked via postMessage) */ - currentIframeUrl: string | null; - /** GitHub repo name if migrated (e.g., "owner/repo"), null if not migrated */ - gitRepoFullName: string | null; -}; - export type ProjectManagerConfig = { project: ProjectWithMessages; trpcClient: AppTRPCClient; @@ -64,7 +55,7 @@ export class ProjectManager { readonly organizationId: string | null; private store: ProjectStore; - private previewPollingState: PreviewPollingState | null = null; + private previewEventsHandle: PreviewEventsHandle | null = null; private trpcClient: AppTRPCClient; private logger: Logger; private streamingCoordinator: V2StreamingCoordinator; @@ -101,7 +92,7 @@ export class ProjectManager { organizationId: this.organizationId, trpcClient: this.trpcClient, store: this.store, - onStreamComplete: () => this.startPreviewPollingIfNeeded(), + onStreamComplete: () => this.handleStreamComplete(), cloudAgentSessionId: this.cloudAgentSessionId, sessionPrepared: project.sessionPrepared, }); @@ -115,8 +106,8 @@ export class ProjectManager { // Existing project with session - reconnect to WebSocket for live updates this.pendingReconnect = true; } else { - // Existing project with no session ID - just start preview polling - this.startPreviewPollingIfNeeded(); + // Existing project with no session ID - just start SSE events + this.startPreviewEventsIfNeeded(); } } @@ -139,9 +130,9 @@ export class ProjectManager { // This guarantees React's subscription setup is complete queueMicrotask(() => { if (!this.destroyed) { - // Start preview polling immediately for faster initial display + // Start SSE events immediately for real-time updates setTimeout(() => { - this.startPreviewPollingIfNeeded(); + this.startPreviewEventsIfNeeded(); }, 100); this.streamingCoordinator.startInitialStreaming(); } @@ -151,7 +142,7 @@ export class ProjectManager { // Reconnect to existing session for live updates queueMicrotask(() => { if (!this.destroyed && this.cloudAgentSessionId) { - this.startPreviewPollingIfNeeded(); + this.startPreviewEventsIfNeeded(); // Connect to WebSocket but don't replay events (undefined fromId) void this.streamingCoordinator.connectToExistingSession(this.cloudAgentSessionId); } @@ -193,6 +184,23 @@ export class ProjectManager { this.streamingCoordinator.interrupt(); } + /** + * Resume from sleeping state: trigger a build and start polling. + * Called when the user clicks "Resume" in the SleepingState UI. + */ + resumeFromSleep(): void { + if (this.destroyed) return; + this.store.setState({ previewStatus: 'building' }); + this.triggerBuild(); + // The previous SSE handle may still be alive (container-stopped doesn't + // call stop()). Tear it down so startPreviewEventsIfNeeded can reconnect. + if (this.previewEventsHandle) { + this.previewEventsHandle.stop(); + this.previewEventsHandle = null; + } + this.startPreviewEventsIfNeeded(); + } + /** Update the GitHub repo full name after migration (e.g., "owner/repo"). */ setGitRepoFullName(repoFullName: string): void { this.store.setState({ gitRepoFullName: repoFullName }); @@ -233,10 +241,10 @@ export class ProjectManager { // Clean up streaming coordinator this.streamingCoordinator.destroy(); - // Stop preview polling - if (this.previewPollingState) { - this.previewPollingState.stop(); - this.previewPollingState = null; + // Stop SSE events + if (this.previewEventsHandle) { + this.previewEventsHandle.stop(); + this.previewEventsHandle = null; } } @@ -244,14 +252,50 @@ export class ProjectManager { // Private Methods // =========================================================================== - private startPreviewPollingIfNeeded(): void { - // Prevent multiple concurrent polling loops - if (this.previewPollingState?.isPolling || this.destroyed) { + /** + * Called when the LLM finishes a streaming turn. + * Triggers a build (wakes a sleeping container). SSE events handle status updates. + */ + private handleStreamComplete(): void { + if (this.destroyed) return; + // Avoid flicker: keep showing the running iframe while the build starts + const currentStatus = this.store.getState().previewStatus; + if (currentStatus !== 'running') { + this.store.setState({ previewStatus: 'building' }); + } + this.triggerBuild(); + this.startPreviewEventsIfNeeded(); + } + + /** + * Trigger a build via tRPC (fire-and-forget). + * SSE events will deliver status updates in real time. + */ + private triggerBuild(): void { + const buildPromise = this.organizationId + ? this.trpcClient.organizations.appBuilder.triggerBuild.mutate({ + projectId: this.projectId, + organizationId: this.organizationId, + }) + : this.trpcClient.appBuilder.triggerBuild.mutate({ + projectId: this.projectId, + }); + + buildPromise.catch(() => { + if (!this.destroyed) { + this.store.setState({ previewStatus: 'error' }); + } + }); + } + + private startPreviewEventsIfNeeded(): void { + // Prevent duplicate SSE connections + if (this.previewEventsHandle || this.destroyed) { return; } - this.logger.log('Starting preview polling'); - this.previewPollingState = startPreviewPolling({ + this.logger.log('Starting SSE events'); + this.previewEventsHandle = startPreviewEvents({ projectId: this.projectId, organizationId: this.organizationId, trpcClient: this.trpcClient, diff --git a/src/components/app-builder/project-manager/__tests__/preview-polling.test.ts b/src/components/app-builder/project-manager/__tests__/preview-polling.test.ts deleted file mode 100644 index 4fca369e8..000000000 --- a/src/components/app-builder/project-manager/__tests__/preview-polling.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * Preview Polling Module Tests - * - * Tests for preview status polling and automatic build triggers. - */ - -import { startPreviewPolling, POLLING_CONFIG } from '../preview-polling'; -import type { PreviewPollingConfig, ProjectStore, ProjectState } from '../types'; - -// Helper to create a mock store for testing -function createMockStore(): ProjectStore & { stateUpdates: Array> } { - const stateUpdates: Array> = []; - let currentState: ProjectState = { - messages: [], - isStreaming: false, - isInterrupting: false, - previewUrl: null, - previewStatus: 'idle', - deploymentId: null, - model: 'anthropic/claude-sonnet-4', - currentIframeUrl: null, - gitRepoFullName: null, - }; - - return { - stateUpdates, - getState: () => currentState, - setState: jest.fn(partial => { - stateUpdates.push(partial); - currentState = { ...currentState, ...partial }; - }), - subscribe: jest.fn(() => () => {}), - updateMessages: jest.fn(), - }; -} - -// Helper to create a mock TRPC client -function createMockTrpcClient(responses: Array<{ status: string; previewUrl?: string }>) { - let callIndex = 0; - - return { - appBuilder: { - getPreviewUrl: { - query: jest.fn(async () => { - const response = responses[callIndex] ?? responses[responses.length - 1]; - callIndex++; - return response; - }), - }, - triggerBuild: { - mutate: jest.fn(async () => ({ success: true })), - }, - }, - organizations: { - appBuilder: { - getPreviewUrl: { - query: jest.fn(async () => { - const response = responses[callIndex] ?? responses[responses.length - 1]; - callIndex++; - return response; - }), - }, - triggerBuild: { - mutate: jest.fn(async () => ({ success: true })), - }, - }, - }, - }; -} - -describe('POLLING_CONFIG', () => { - it('exports expected configuration values', () => { - expect(POLLING_CONFIG.maxAttempts).toBe(30); - expect(POLLING_CONFIG.baseDelay).toBe(2000); - expect(POLLING_CONFIG.maxDelay).toBe(10000); - expect(POLLING_CONFIG.idleThresholdForBuild).toBe(2); - }); -}); - -describe('startPreviewPolling', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns polling state with stop function', () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([ - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - const pollingState = startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - expect(pollingState).toHaveProperty('isPolling'); - expect(pollingState).toHaveProperty('stop'); - expect(typeof pollingState.stop).toBe('function'); - }); - - it('sets previewStatus to building when starting', async () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([ - { status: 'building' }, - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - // Initial state should be set to building - expect(store.stateUpdates).toContainEqual({ previewStatus: 'building' }); - }); - - it('sets previewUrl and status to running when preview is ready', async () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([ - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - // Let the polling complete - await jest.runAllTimersAsync(); - - expect(store.stateUpdates).toContainEqual({ - previewUrl: 'http://preview.test', - previewStatus: 'running', - }); - }); - - it('sets previewStatus to error on error response', async () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([{ status: 'error' }]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - await jest.runAllTimersAsync(); - - expect(store.stateUpdates).toContainEqual({ previewStatus: 'error' }); - }); - - it('stops polling when destroyed', async () => { - const store = createMockStore(); - let destroyed = false; - const trpcClient = createMockTrpcClient([ - { status: 'building' }, - { status: 'building' }, - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => destroyed, - }); - - // Let first poll complete - await jest.advanceTimersByTimeAsync(100); - - // Mark as destroyed - destroyed = true; - - // Let remaining polls try to run - await jest.runAllTimersAsync(); - - // Should not reach running status since we destroyed - const hasRunningStatus = store.stateUpdates.some(update => update.previewStatus === 'running'); - expect(hasRunningStatus).toBe(false); - }); - - it('stops polling when stop() is called', async () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([ - { status: 'building' }, - { status: 'building' }, - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - const pollingState = startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - // Let first poll complete - await jest.advanceTimersByTimeAsync(100); - - // Stop polling - pollingState.stop(); - - // Let remaining timers try to run - await jest.runAllTimersAsync(); - - // isPolling should be false after stop - expect(pollingState.isPolling).toBe(false); - }); - - it('triggers build after consecutive idle responses', async () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([ - { status: 'idle' }, - { status: 'idle' }, - { status: 'building' }, - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - await jest.runAllTimersAsync(); - - // Should have called triggerBuild after 2 consecutive idle responses - expect(trpcClient.appBuilder.triggerBuild.mutate).toHaveBeenCalledWith({ - projectId: 'project-1', - }); - }); - - it('uses organization path for organization projects', async () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([ - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: 'org-123', - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - await jest.runAllTimersAsync(); - - expect(trpcClient.organizations.appBuilder.getPreviewUrl.query).toHaveBeenCalledWith({ - projectId: 'project-1', - organizationId: 'org-123', - }); - }); - - it('does not set building status if already running', async () => { - const store = createMockStore(); - // Set initial status to running - store.setState({ previewStatus: 'running' }); - store.stateUpdates.length = 0; // Clear the setState we just did - - const trpcClient = createMockTrpcClient([ - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - await jest.runAllTimersAsync(); - - // Should not have set previewStatus to 'building' since it was already 'running' - const hasBuildingStatus = store.stateUpdates.some( - update => update.previewStatus === 'building' - ); - expect(hasBuildingStatus).toBe(false); - }); - - it('sets error status after max attempts reached', async () => { - const store = createMockStore(); - // Create many building responses to exhaust max attempts - const responses = Array(35).fill({ status: 'building' }); - const trpcClient = createMockTrpcClient(responses); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - await jest.runAllTimersAsync(); - - // Should eventually set error status after max attempts - const hasErrorStatus = store.stateUpdates.some(update => update.previewStatus === 'error'); - expect(hasErrorStatus).toBe(true); - }); - - it('resets idle count when non-idle status received', async () => { - const store = createMockStore(); - const trpcClient = createMockTrpcClient([ - { status: 'idle' }, - { status: 'building' }, // This breaks the idle streak - { status: 'idle' }, - { status: 'running', previewUrl: 'http://preview.test' }, - ]); - - startPreviewPolling({ - projectId: 'project-1', - organizationId: null, - trpcClient: trpcClient as unknown as PreviewPollingConfig['trpcClient'], - store, - isDestroyed: () => false, - }); - - await jest.runAllTimersAsync(); - - // triggerBuild should NOT be called because "building" broke the idle streak - // before reaching threshold of 2 consecutive idle responses - expect(trpcClient.appBuilder.triggerBuild.mutate).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/app-builder/project-manager/preview-polling.ts b/src/components/app-builder/project-manager/preview-polling.ts deleted file mode 100644 index ded83b4cb..000000000 --- a/src/components/app-builder/project-manager/preview-polling.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Preview Polling Module - * - * Handles polling for preview status and automatic build triggers. - */ - -import type { PreviewPollingConfig, PreviewPollingState } from './types'; - -export type { PreviewPollingConfig, PreviewPollingState }; - -/** - * Default polling configuration. - */ -export const POLLING_CONFIG = { - maxAttempts: 30, - baseDelay: 2000, - maxDelay: 10000, - idleThresholdForBuild: 2, -} as const; - -/** - * Starts polling for preview status. - * Returns a control object to stop polling. - */ -export function startPreviewPolling(config: PreviewPollingConfig): PreviewPollingState { - const { projectId, organizationId, trpcClient, store, isDestroyed } = config; - - // Polling state (mutable reference for the closure) - const pollingState: PreviewPollingState = { - isPolling: true, - stop: () => { - pollingState.isPolling = false; - }, - }; - - // Start the polling loop asynchronously - void runPollingLoop(pollingState, projectId, organizationId, trpcClient, store, isDestroyed); - - return pollingState; -} - -/** - * The main polling loop. - */ -async function runPollingLoop( - pollingState: PreviewPollingState, - projectId: string, - organizationId: string | null, - trpcClient: PreviewPollingConfig['trpcClient'], - store: PreviewPollingConfig['store'], - isDestroyed: () => boolean -): Promise { - const currentStatus = store.getState().previewStatus; - - // Only set to 'building' if not already 'running' to avoid visual flicker - if (currentStatus !== 'running') { - store.setState({ previewStatus: 'building' }); - } - - // Track consecutive idle responses to trigger automatic build - let consecutiveIdleCount = 0; - - try { - for (let attempt = 0; attempt < POLLING_CONFIG.maxAttempts; attempt++) { - // Check if polling should stop - if (!pollingState.isPolling || isDestroyed()) { - return; - } - - const result = organizationId - ? await trpcClient.organizations.appBuilder.getPreviewUrl.query({ - projectId, - organizationId, - }) - : await trpcClient.appBuilder.getPreviewUrl.query({ - projectId, - }); - - // Check again after async operation - if (!pollingState.isPolling || isDestroyed()) { - return; - } - - if (result.status === 'running' && result.previewUrl) { - store.setState({ previewUrl: result.previewUrl, previewStatus: 'running' }); - return; - } - - if (result.status === 'error') { - store.setState({ previewStatus: 'error' }); - return; - } - - // Track consecutive idle responses and trigger build automatically - if (result.status === 'idle') { - consecutiveIdleCount++; - - if (consecutiveIdleCount >= POLLING_CONFIG.idleThresholdForBuild) { - try { - if (organizationId) { - await trpcClient.organizations.appBuilder.triggerBuild.mutate({ - projectId, - organizationId, - }); - } else { - await trpcClient.appBuilder.triggerBuild.mutate({ - projectId, - }); - } - store.setState({ previewStatus: 'building' }); - } catch { - // Build trigger failed, continue polling - } - } - } else { - consecutiveIdleCount = 0; - } - - const delay = Math.min( - POLLING_CONFIG.baseDelay * Math.pow(1.5, attempt), - POLLING_CONFIG.maxDelay - ); - await new Promise(resolve => setTimeout(resolve, delay)); - } - - // Max attempts reached - store.setState({ previewStatus: 'error' }); - } catch { - // Polling error - set error status unless destroyed - if (!isDestroyed()) { - store.setState({ previewStatus: 'error' }); - } - } finally { - pollingState.isPolling = false; - } -} diff --git a/src/components/app-builder/project-manager/types.ts b/src/components/app-builder/project-manager/types.ts index 395ba44ee..3185ceef8 100644 --- a/src/components/app-builder/project-manager/types.ts +++ b/src/components/app-builder/project-manager/types.ts @@ -19,7 +19,7 @@ export type AppTRPCClient = TRPCClient; // State Types // ============================================================================= -export type PreviewStatus = 'idle' | 'building' | 'running' | 'error'; +export type PreviewStatus = 'idle' | 'building' | 'running' | 'error' | 'sleeping'; export type ProjectState = { messages: CloudMessage[]; @@ -60,19 +60,10 @@ export type ProjectManagerConfig = { }; // ============================================================================= -// Preview Polling Types +// Preview Events (SSE) Types // ============================================================================= -export type PreviewPollingConfig = { - projectId: string; - organizationId: string | null; - trpcClient: AppTRPCClient; - store: ProjectStore; - isDestroyed: () => boolean; -}; - -export type PreviewPollingState = { - isPolling: boolean; +export type PreviewEventsHandle = { stop: () => void; }; diff --git a/src/components/app-builder/project-manager/usePreviewEvents.ts b/src/components/app-builder/project-manager/usePreviewEvents.ts new file mode 100644 index 000000000..4689e1578 --- /dev/null +++ b/src/components/app-builder/project-manager/usePreviewEvents.ts @@ -0,0 +1,287 @@ +/** + * SSE-based preview events hook. + * + * Replaces polling with a persistent Server-Sent Events connection to the + * Cloudflare worker for real-time preview status, build logs, and sleep events. + * + * Reconnection uses exponential backoff with jitter and fetches a fresh JWT + * ticket on each reconnect (tickets expire after 5 minutes). + */ + +import type { AppTRPCClient, PreviewStatus, ProjectStore } from './types'; + +const PREVIEW_STATUSES: readonly PreviewStatus[] = [ + 'idle', + 'building', + 'running', + 'error', + 'sleeping', +]; + +function isPreviewStatus(s: string): s is PreviewStatus { + return (PREVIEW_STATUSES as readonly string[]).includes(s); +} + +/** + * SSE event types mirrored from cloudflare-app-builder/src/types.ts. + * Duplicated here to avoid pulling Cloudflare-specific ambient types + * (CloudflareEnv, DurableObjectNamespace, etc.) into the Next.js compilation. + */ +type AppBuilderEvent = + | { type: 'status'; state: string; previewUrl?: string } + | { type: 'log'; source: 'build' | 'dev-server'; message: string; timestamp: string } + | { type: 'error'; message: string } + | { type: 'container-stopped' }; + +export type PreviewEventsConfig = { + projectId: string; + organizationId: string | null; + trpcClient: AppTRPCClient; + store: ProjectStore; + isDestroyed: () => boolean; +}; + +type PreviewEventsHandle = { + stop: () => void; +}; + +const MAX_RECONNECT_ATTEMPTS = 8; +const BASE_DELAY_MS = 1_000; +const MAX_DELAY_MS = 30_000; + +function reconnectDelay(attempt: number): number { + const exponential = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt)); + // Jitter: 0.5x–1.5x + return exponential * (0.5 + Math.random()); +} + +async function fetchTicket( + projectId: string, + organizationId: string | null, + trpcClient: AppTRPCClient +): Promise<{ ticket: string; workerUrl: string }> { + const result = organizationId + ? await trpcClient.organizations.appBuilder.getEventsTicket.query({ + projectId, + organizationId, + }) + : await trpcClient.appBuilder.getEventsTicket.query({ projectId }); + + return { ticket: result.ticket, workerUrl: result.workerUrl }; +} + +/** + * Parse an SSE text chunk into events. + * Handles the `event:` and `data:` fields of the SSE protocol. + * Comment lines (starting with `:`) are silently ignored (keepalives). + */ +function parseSSEChunk(chunk: string): AppBuilderEvent[] { + const events: AppBuilderEvent[] = []; + const lines = chunk.split('\n'); + + let currentData: string | null = null; + + for (const line of lines) { + if (line.startsWith(':')) { + // SSE comment (keepalive), ignore + continue; + } + + if (line.startsWith('data: ')) { + currentData = line.slice(6); + } else if (line === '' && currentData !== null) { + // Empty line = end of event + try { + const parsed = JSON.parse(currentData); + events.push(parsed); + } catch { + // Malformed JSON, skip + } + currentData = null; + } + } + + return events; +} + +function handleEvent(event: AppBuilderEvent, store: ProjectStore): void { + switch (event.type) { + case 'status': { + // Map worker's PreviewState to our PreviewStatus + // 'uninitialized' has no UI equivalent — treat it as 'idle' + const previewStatus: PreviewStatus = + event.state === 'uninitialized' + ? 'idle' + : isPreviewStatus(event.state) + ? event.state + : 'idle'; + const partial: Partial> = { previewStatus }; + if (event.previewUrl) { + partial.previewUrl = event.previewUrl; + } + store.setState(partial); + break; + } + case 'log': + // Log events are available for future UI use (build terminal, etc.) + // For now, they are not surfaced in the UI. + break; + case 'error': + // Error details are available for future UI use + break; + case 'container-stopped': + store.setState({ previewStatus: 'sleeping' }); + break; + } +} + +async function connectAndStream( + config: PreviewEventsConfig, + abortController: AbortController +): Promise<'container-stopped' | 'destroyed' | 'exhausted'> { + const { projectId, organizationId, trpcClient, store, isDestroyed } = config; + const { signal } = abortController; + + let attempt = 0; + + while (attempt <= MAX_RECONNECT_ATTEMPTS && !isDestroyed()) { + let containerStopped = false; + + try { + const { ticket, workerUrl } = await fetchTicket(projectId, organizationId, trpcClient); + if (isDestroyed()) return 'destroyed'; + + const url = `${workerUrl}/apps/${encodeURIComponent(projectId)}/events?ticket=${encodeURIComponent(ticket)}`; + const response = await fetch(url, { + headers: { Accept: 'text/event-stream' }, + signal, + }); + + if (!response.ok) { + throw new Error(`SSE connection failed: ${response.status}`); + } + + if (!response.body) { + throw new Error('No response body'); + } + + // Reset attempt counter on successful connection + attempt = 0; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + // Abort the reader when the controller fires + signal.addEventListener('abort', () => reader.cancel().catch(() => {}), { once: true }); + + while (!isDestroyed()) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete events (delimited by double newlines) + const parts = buffer.split('\n\n'); + // Keep the last incomplete part in the buffer + buffer = parts.pop() ?? ''; + + for (const part of parts) { + if (!part.trim()) continue; + const events = parseSSEChunk(part + '\n\n'); + for (const event of events) { + handleEvent(event, store); + if (event.type === 'container-stopped') { + containerStopped = true; + } + } + } + } + + // Stream ended cleanly (server closed). If not destroyed, reconnect. + if (isDestroyed()) return 'destroyed'; + + // Don't reconnect after container sleep — wait for visibility change + if (containerStopped) return 'container-stopped'; + } catch { + if (isDestroyed()) return 'destroyed'; + } + + attempt++; + if (attempt > MAX_RECONNECT_ATTEMPTS) { + // Give up — set error state + store.setState({ previewStatus: 'error' }); + return 'exhausted'; + } + + const delay = reconnectDelay(attempt); + await new Promise(resolve => setTimeout(resolve, delay)); + } + + return 'destroyed'; +} + +/** + * Start listening for preview events via SSE. + * Returns a handle to stop the connection. + * + * After a `container-stopped` event, SSE does not auto-reconnect. Instead, + * a `visibilitychange` listener reconnects when the user returns to the tab. + * This ensures `eventWriters.size === 0` on the DO is a reliable "no active + * user" signal for deferring push-triggered builds. + */ +export function startPreviewEvents(config: PreviewEventsConfig): PreviewEventsHandle { + let stopped = false; + const abortController = new AbortController(); + + const isStopped = () => stopped || config.isDestroyed(); + + function startConnection(): void { + const controller = new AbortController(); + + // Forward top-level abort to per-connection controller + abortController.signal.addEventListener('abort', () => controller.abort(), { once: true }); + + const innerConfig: PreviewEventsConfig = { + ...config, + isDestroyed: isStopped, + }; + + void connectAndStream(innerConfig, controller).then(reason => { + if (reason !== 'container-stopped') return; + if (isStopped()) return; + + // If the tab is already visible, reconnect immediately. + // Otherwise wait for visibility change before reconnecting. + // This triggers subscribeEvents() on the DO which checks pendingBuild. + if (document.visibilityState === 'visible') { + startConnection(); + return; + } + + const onVisibilityChange = () => { + if (document.visibilityState !== 'visible') return; + document.removeEventListener('visibilitychange', onVisibilityChange); + if (isStopped()) return; + startConnection(); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + + // Clean up listener on stop + abortController.signal.addEventListener( + 'abort', + () => document.removeEventListener('visibilitychange', onVisibilityChange), + { once: true } + ); + }); + } + + startConnection(); + + return { + stop: () => { + stopped = true; + abortController.abort(); + }, + }; +} diff --git a/src/lib/app-builder/app-builder-client.ts b/src/lib/app-builder/app-builder-client.ts index b765751f7..82ab19cdc 100644 --- a/src/lib/app-builder/app-builder-client.ts +++ b/src/lib/app-builder/app-builder-client.ts @@ -131,7 +131,7 @@ export async function getPreview(projectId: string): Promise * Trigger a build for a project. * * This is a fire-and-forget operation. The build runs asynchronously. - * Use getPreview() to poll for status or streamBuildLogs() to watch progress. + * Use getPreview() to check status. * * @param projectId - The unique identifier for the project * @throws AppBuilderError if the request fails @@ -159,20 +159,23 @@ export async function triggerBuild(projectId: string): Promise { } /** - * Stream build logs for a project via Server-Sent Events. + * Notify the App Builder worker of a git push. + * + * Unlike triggerBuild(), this defers the build when no user is actively + * viewing the preview (no SSE connections). The deferred build runs + * automatically when a user reconnects. * * @param projectId - The unique identifier for the project - * @returns A ReadableStream of SSE events containing build logs - * @throws AppBuilderError if the request fails or no logs are available + * @throws AppBuilderError if the request fails */ -export async function streamBuildLogs(projectId: string): Promise> { +export async function notifyGitPush(projectId: string): Promise { const baseUrl = getBaseUrl(); - const endpoint = `${baseUrl}/apps/${encodeURIComponent(projectId)}/build/logs`; + const endpoint = `${baseUrl}/apps/${encodeURIComponent(projectId)}/push`; const response = await fetch(endpoint, { - method: 'GET', + method: 'POST', headers: { - Accept: 'text/event-stream', + 'Content-Type': 'application/json', ...(APP_BUILDER_AUTH_TOKEN && { Authorization: `Bearer ${APP_BUILDER_AUTH_TOKEN}` }), }, }); @@ -180,21 +183,11 @@ export async function streamBuildLogs(projectId: string): Promise 'Unknown error'); throw new AppBuilderError( - `Failed to stream build logs for project ${projectId}: ${response.status} ${response.statusText} - ${errorText}`, - response.status, - endpoint - ); - } - - if (!response.body) { - throw new AppBuilderError( - `No response body for build logs stream for project ${projectId}`, + `Failed to notify git push for project ${projectId}: ${response.status} ${response.statusText} - ${errorText}`, response.status, endpoint ); } - - return response.body; } /** diff --git a/src/lib/app-builder/events-ticket.ts b/src/lib/app-builder/events-ticket.ts new file mode 100644 index 000000000..2746da5e3 --- /dev/null +++ b/src/lib/app-builder/events-ticket.ts @@ -0,0 +1,32 @@ +import jwt from 'jsonwebtoken'; +import { APP_BUILDER_TICKET_SECRET, APP_BUILDER_URL } from '@/lib/config.server'; + +export function signEventTicket( + projectId: string, + userId: string +): { ticket: string; expiresAt: number; workerUrl: string } { + if (!APP_BUILDER_TICKET_SECRET) { + throw new Error('APP_BUILDER_TICKET_SECRET is not configured'); + } + if (!APP_BUILDER_URL) { + throw new Error('APP_BUILDER_URL is not configured'); + } + + const expiresInSeconds = 300; // 5 minutes + const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds; + + const ticket = jwt.sign( + { + type: 'app_builder_event', + userId, + projectId, + }, + APP_BUILDER_TICKET_SECRET, + { + algorithm: 'HS256', + expiresIn: expiresInSeconds, + } + ); + + return { ticket, expiresAt, workerUrl: APP_BUILDER_URL }; +} diff --git a/src/lib/config.server.ts b/src/lib/config.server.ts index 3978873be..f807190d1 100644 --- a/src/lib/config.server.ts +++ b/src/lib/config.server.ts @@ -92,6 +92,8 @@ export const MILVUS_TOKEN = getEnvVariable('MILVUS_TOKEN'); export const APP_BUILDER_URL = getEnvVariable('APP_BUILDER_URL'); export const APP_BUILDER_AUTH_TOKEN = getEnvVariable('APP_BUILDER_AUTH_TOKEN'); +export const APP_BUILDER_TICKET_SECRET = getEnvVariable('APP_BUILDER_TICKET_SECRET'); + // App Builder DB Proxy export const APP_BUILDER_DB_PROXY_URL = getEnvVariable('APP_BUILDER_DB_PROXY_URL'); export const APP_BUILDER_DB_PROXY_AUTH_TOKEN = getEnvVariable('APP_BUILDER_DB_PROXY_AUTH_TOKEN'); diff --git a/src/lib/integrations/platforms/github/webhook-handlers/push-handler.ts b/src/lib/integrations/platforms/github/webhook-handlers/push-handler.ts index f60ce93ad..45ac3809d 100644 --- a/src/lib/integrations/platforms/github/webhook-handlers/push-handler.ts +++ b/src/lib/integrations/platforms/github/webhook-handlers/push-handler.ts @@ -12,7 +12,7 @@ import { PLATFORM } from '@/lib/integrations/core/constants'; import { logExceptInTest } from '@/lib/utils.server'; import type { PushEventPayload } from '@/lib/integrations/platforms/github/webhook-schemas'; import { extractBranchNameFromRef } from '@/lib/integrations/platforms/github/utils'; -import { triggerBuild } from '@/lib/app-builder/app-builder-client'; +import { notifyGitPush } from '@/lib/app-builder/app-builder-client'; export async function handlePushEvent(event: PushEventPayload, integration: PlatformIntegration) { const branchName = extractBranchNameFromRef(event.ref); @@ -99,7 +99,7 @@ async function rebuildMatchingAppBuilderPreviews( await Promise.allSettled( projects.map(async ({ id }) => { try { - await triggerBuild(id); + await notifyGitPush(id); } catch (error) { logExceptInTest('Failed to trigger app builder preview rebuild', { projectId: id, diff --git a/src/routers/app-builder-router.ts b/src/routers/app-builder-router.ts index 9cbf5f809..f24d5a573 100644 --- a/src/routers/app-builder-router.ts +++ b/src/routers/app-builder-router.ts @@ -13,6 +13,7 @@ import { import { getBalanceForUser } from '@/lib/user.balance'; import { MIN_BALANCE_FOR_APP_BUILDER } from '@/lib/app-builder/constants'; import { generateImageUploadUrl } from '@/lib/r2/cloud-agent-attachments'; +import { signEventTicket } from '@/lib/app-builder/events-ticket'; export const appBuilderRouter = createTRPCRouter({ /** @@ -36,6 +37,19 @@ export const appBuilderRouter = createTRPCRouter({ }); }), + /** + * Get a short-lived JWT ticket for connecting to the SSE events stream. + * The ticket is verified by the worker to authenticate the EventSource connection. + */ + getEventsTicket: baseProcedure.input(projectIdBaseSchema).query(async ({ ctx, input }) => { + // getPreviewUrl already validates project ownership, but we need to validate here too + // since this ticket grants access to the event stream + const owner = { type: 'user' as const, id: ctx.user.id }; + await appBuilderService.getPreviewUrl(input.projectId, owner); + + return signEventTicket(input.projectId, ctx.user.id); + }), + /** * Get preview URL for a project */ diff --git a/src/routers/organizations/organization-app-builder-router.ts b/src/routers/organizations/organization-app-builder-router.ts index 8b6068ec4..0931d9bcb 100644 --- a/src/routers/organizations/organization-app-builder-router.ts +++ b/src/routers/organizations/organization-app-builder-router.ts @@ -15,6 +15,7 @@ import { import { getBalanceForOrganizationUser } from '@/lib/organizations/organization-usage'; import { MIN_BALANCE_FOR_APP_BUILDER } from '@/lib/app-builder/constants'; import { generateImageUploadUrl } from '@/lib/r2/cloud-agent-attachments'; +import { signEventTicket } from '@/lib/app-builder/events-ticket'; // Input schemas with required organizationId const createProjectSchema = createProjectBaseSchema.merge(organizationIdSchema); @@ -49,6 +50,18 @@ export const organizationAppBuilderRouter = createTRPCRouter({ }); }), + /** + * Get a short-lived JWT ticket for connecting to the SSE events stream. + */ + getEventsTicket: organizationMemberProcedure + .input(projectWithOrgIdSchema) + .query(async ({ ctx, input }) => { + const owner = { type: 'org' as const, id: input.organizationId }; + await appBuilderService.getPreviewUrl(input.projectId, owner); + + return signEventTicket(input.projectId, ctx.user.id); + }), + /** * Get preview URL for a project */ From ecdd58a9249b05d4632cd9ea85482b75b4d7e4f1 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 16 Feb 2026 11:07:46 +0100 Subject: [PATCH 3/3] Fix CORS headers for SSE events endpoint and add 'sleeping' to PreviewStateSchema - Add CORS preflight and response headers to /apps/{appId}/events route (browser fetches SSE cross-origin from app.kilo.ai) - Add 'sleeping' to PreviewStateSchema zod enum so client-side parsing doesn't reject the new state from getStatus() - De-duplicate PreviewState: types.ts now re-exports from api-schemas.ts instead of maintaining a separate union type --- cloudflare-app-builder/src/api-schemas.ts | 9 ++++++++- cloudflare-app-builder/src/index.ts | 12 ++++++++++-- cloudflare-app-builder/src/types.ts | 13 ++++--------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/cloudflare-app-builder/src/api-schemas.ts b/cloudflare-app-builder/src/api-schemas.ts index 196a13fd1..17d321c83 100644 --- a/cloudflare-app-builder/src/api-schemas.ts +++ b/cloudflare-app-builder/src/api-schemas.ts @@ -57,7 +57,14 @@ export type InitResponse = z.infer; // GET /apps/{app_id}/preview // ============================================ -export const PreviewStateSchema = z.enum(['uninitialized', 'idle', 'building', 'running', 'error']); +export const PreviewStateSchema = z.enum([ + 'uninitialized', + 'idle', + 'building', + 'running', + 'error', + 'sleeping', +]); export type PreviewState = z.infer; export const GetPreviewResponseSchema = z.object({ diff --git a/cloudflare-app-builder/src/index.ts b/cloudflare-app-builder/src/index.ts index e1d407b82..fd04ed434 100644 --- a/cloudflare-app-builder/src/index.ts +++ b/cloudflare-app-builder/src/index.ts @@ -163,9 +163,17 @@ export default { } // Handle SSE event stream (GET /apps/{app_id}/events) + // This is fetched cross-origin from app.kilo.ai, so needs CORS headers. const eventsMatch = pathname.match(EVENTS_PATTERN); - if (eventsMatch && request.method === 'GET') { - return handleEvents(request, env, eventsMatch[1]); + if (eventsMatch) { + const allowedOrigin = getAllowedOrigin(request, env); + if (allowedOrigin && request.method === 'OPTIONS') { + return withCorsHeaders(new Response(null, { status: 204 }), allowedOrigin); + } + if (request.method === 'GET') { + const response = await handleEvents(request, env, eventsMatch[1]); + return allowedOrigin ? withCorsHeaders(response, allowedOrigin) : response; + } } // Handle migrate to GitHub requests (POST /apps/{app_id}/migrate-to-github) diff --git a/cloudflare-app-builder/src/types.ts b/cloudflare-app-builder/src/types.ts index e338819e6..1dc43fdcd 100644 --- a/cloudflare-app-builder/src/types.ts +++ b/cloudflare-app-builder/src/types.ts @@ -4,6 +4,9 @@ // Environment Types // ============================================ import type { Sandbox } from '@cloudflare/sandbox'; +import type { PreviewState } from './api-schemas'; + +export type { PreviewState }; export const DEFAULT_SANDBOX_PORT = 8080; @@ -146,15 +149,7 @@ export interface ErrnoException extends Error { // Preview Types // ============================================ -/** - * Possible states for a preview sandbox - * - uninitialized: DO not configured with appId yet - * - idle: appId set, no dev server process running - * - building: dev server process exists, port not yet open - * - running: dev server process exists AND port is open - * - error: process failed/killed (auto-clears on next build) - */ -export type PreviewState = 'uninitialized' | 'idle' | 'building' | 'running' | 'error' | 'sleeping'; +// PreviewState is re-exported from api-schemas.ts (single source of truth via zod enum) /** * Database credentials - both fields are always present together