Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions cloudflare-app-builder/src/api-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ export type InitResponse = z.infer<typeof InitResponseSchema>;
// 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<typeof PreviewStateSchema>;

export const GetPreviewResponseSchema = z.object({
Expand All @@ -82,20 +89,6 @@ export const BuildTriggerErrorResponseSchema = z.object({

export type BuildTriggerErrorResponse = z.infer<typeof BuildTriggerErrorResponseSchema>;

// ============================================
// 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<typeof BuildLogsErrorResponseSchema>;

// ============================================
// Token Generation Endpoint Schemas
// POST /apps/{app_id}/token
Expand Down
65 changes: 65 additions & 0 deletions cloudflare-app-builder/src/app-builder-sandbox.ts
Original file line number Diff line number Diff line change
@@ -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<Env> {
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<void> {
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',
});
}
}
76 changes: 76 additions & 0 deletions cloudflare-app-builder/src/handlers/events.ts
Original file line number Diff line number Diff line change
@@ -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<PreviewDO> {
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<Response> {
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' },
}
);
}
}
4 changes: 2 additions & 2 deletions cloudflare-app-builder/src/handlers/git-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
})
);
}
Expand Down
33 changes: 5 additions & 28 deletions cloudflare-app-builder/src/handlers/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,8 @@ export async function handleTriggerBuild(
}
}

export async function handleStreamBuildLogs(
request: Request,
env: Env,
appId: string
): Promise<Response> {
export async function handleGitPush(request: Request, env: Env, appId: string): Promise<Response> {
try {
// 1. Verify Bearer token authentication
const authResult = verifyBearerToken(request, env);
if (!authResult.isAuthenticated) {
if (!authResult.errorResponse) {
Expand All @@ -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',
Expand Down
64 changes: 55 additions & 9 deletions cloudflare-app-builder/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,}';
Expand All @@ -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})$`);

Expand Down Expand Up @@ -62,6 +64,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
*/
Expand All @@ -86,8 +110,16 @@ export default {
});

if (subdomainAppId) {
const allowedOrigin = getAllowedOrigin(request, env);

// Handle CORS preflight for cross-origin requests 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
Expand Down Expand Up @@ -124,10 +156,24 @@ 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)
// This is fetched cross-origin from app.kilo.ai, so needs CORS headers.
const eventsMatch = pathname.match(EVENTS_PATTERN);
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)
Expand Down
Loading